From 168de11d4d370bd81dc1e6ffa664eeaa2fc69ffb Mon Sep 17 00:00:00 2001 From: integr-dev Date: Tue, 3 Mar 2026 19:44:51 +0100 Subject: [PATCH 1/3] Implement improvements and preventative fixes for the event system and scripting system. Added dead handler cleanup and benchmarks for the EventBus. --- .../kotlin/net/integr/backbone/Backbone.kt | 10 +- .../net/integr/backbone/BackboneLogger.kt | 19 + .../backbone/systems/entity/EntityHandler.kt | 7 +- .../integr/backbone/systems/event/EventBus.kt | 125 ++++- .../systems/hotloader/ScriptEngine.kt | 6 +- .../systems/hotloader/ScriptLinker.kt | 2 +- .../lifecycle/LifecycleSustainedState.kt | 8 + .../hotloader/lifecycle/ManagedLifecycle.kt | 24 +- .../systems/event/EventBusBenchmark.kt | 497 ++++++++++++++++++ .../backbone/systems/event/EventBusTest.kt | 237 +++++++++ 10 files changed, 900 insertions(+), 35 deletions(-) create mode 100644 src/test/kotlin/net/integr/backbone/systems/event/EventBusBenchmark.kt diff --git a/src/main/kotlin/net/integr/backbone/Backbone.kt b/src/main/kotlin/net/integr/backbone/Backbone.kt index ff20086..bf6ab46 100644 --- a/src/main/kotlin/net/integr/backbone/Backbone.kt +++ b/src/main/kotlin/net/integr/backbone/Backbone.kt @@ -144,28 +144,28 @@ object Backbone { /** * Registers a listener to the server's plugin manager and the internal event bus. + * 1. Registers the listener with the internal event bus. + * 2. Registers the listener with the plugin manager. * * @param listener The listener to register. * * @since 1.0.0 */ fun registerListener(listener: Listener) { - LOGGER.info("Registering listener: ${listener.javaClass.name}") - SERVER.pluginManager.registerEvents(listener, PLUGIN) EventBus.register(listener) + SERVER.pluginManager.registerEvents(listener, PLUGIN) } /** * Removes a listener from the server's plugin manager and the internal event bus. * - * @param listener The listener to register. + * @param listener The listener to unregister. * * @since 1.0.0 */ fun unregisterListener(listener: Listener) { - LOGGER.info("Unregistering listener: ${listener.javaClass.name}") - HandlerList.unregisterAll(listener) EventBus.unregister(listener) + HandlerList.unregisterAll(listener) } /** diff --git a/src/main/kotlin/net/integr/backbone/BackboneLogger.kt b/src/main/kotlin/net/integr/backbone/BackboneLogger.kt index 8d25198..5010603 100644 --- a/src/main/kotlin/net/integr/backbone/BackboneLogger.kt +++ b/src/main/kotlin/net/integr/backbone/BackboneLogger.kt @@ -71,7 +71,26 @@ class BackboneLogger(name: String) : Logger(name, null) { if (record.level == Level.SEVERE) logFile.appendText(fileMessage + "\n") } + /** + * Flushes any buffered output. + * + * This implementation is intentionally empty as the handler writes directly to + * the console (via `println`) and file (via `appendText`), both of which handle + * their own flushing automatically. + * + * @since 1.0.0 + */ override fun flush() {} + + /** + * Closes the handler and releases any associated resources. + * + * This implementation is intentionally empty as the handler does not maintain + * any resources that require explicit cleanup. The log file is opened and closed + * on each write operation, and console output requires no cleanup. + * + * @since 1.0.0 + */ override fun close() {} } diff --git a/src/main/kotlin/net/integr/backbone/systems/entity/EntityHandler.kt b/src/main/kotlin/net/integr/backbone/systems/entity/EntityHandler.kt index 7d60931..8468f8d 100644 --- a/src/main/kotlin/net/integr/backbone/systems/entity/EntityHandler.kt +++ b/src/main/kotlin/net/integr/backbone/systems/entity/EntityHandler.kt @@ -79,8 +79,13 @@ object EntityHandler : Listener { for (entity in event.entities) { val id = PersistenceHelper.read(entity, PersistenceKeys.BACKBONE_CUSTOM_ENTITY_UID.key, PersistentDataType.STRING) if (id != null) { + val customEntity = entities[id] + if (customEntity == null) { + logger.warning("Custom entity not found for entity: ${entity.entityId} is '$id' at ${entity.location}") + continue + } + logger.info("Re-creating goals for entity: ${entity.entityId} is '$id' at ${entity.location}") - val customEntity = entities[id] ?: throw IllegalArgumentException("Entity not found.") customEntity.recreateGoals(entity as Mob) } } diff --git a/src/main/kotlin/net/integr/backbone/systems/event/EventBus.kt b/src/main/kotlin/net/integr/backbone/systems/event/EventBus.kt index 7943f68..ed27ff2 100644 --- a/src/main/kotlin/net/integr/backbone/systems/event/EventBus.kt +++ b/src/main/kotlin/net/integr/backbone/systems/event/EventBus.kt @@ -17,34 +17,76 @@ package net.integr.backbone.systems.event import net.integr.backbone.Backbone import org.jetbrains.annotations.ApiStatus +import java.lang.ref.WeakReference import java.lang.reflect.InvocationTargetException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentSkipListSet import kotlin.reflect.KCallable import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.full.defaultType import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.starProjectedType /** * A simple event bus for publishing and subscribing to events. * - * This class provides methods for registering and unregistering event handlers, + * This class provides methods for registering and unregistering event handlers * and for posting events to be handled by registered handlers. * * @since 1.0.0 */ object EventBus { - private val logger = Backbone.LOGGER.derive("event-system") + private val logger = Backbone.LOGGER.derive("event-bus") /** - * Represents a singular handler with its priority, member and containing class instance. + * Represents a singular handler with its priority, member, and containing class instance. + * + * This class uses a weak reference to hold the instance, allowing the garbage collector + * to reclaim the instance if it's no longer referenced elsewhere. Handlers are comparable + * based on their priority, callable, and instance identity. It uses weak references to + * prevent memory leaks. * * @param priority the priority of the handler * @param callable the method to invoke when firing * @param instance the instance of the containing class to call the member in * @since 1.1.0 */ - private class EventHandler(var priority: Int, var callable: KCallable<*>, var instance: Any) : Comparable { + private class EventHandler( + var priority: Int, + var callable: KCallable<*>, + instance: Any + ) : Comparable { + private val instanceRef = WeakReference(instance) + + /** + * The instance of the containing class, or null if it has been garbage collected. + * + * @since 1.1.0 + */ + val instance: Any? + get() = instanceRef.get() + + /** + * Whether the handler's instance is still alive (not garbage collected). + * + * @return true if the instance is still available, false otherwise + * @since 1.1.0 + */ + val isAlive: Boolean + get() = instanceRef.get() != null + + /** + * Compares this handler to another object for equality. + * + * Two handlers are considered equal if they have the same comparison result + * (same priority, callable, and instance). + * + * @param other the object to compare with + * @return true if the handlers are equal, false otherwise + * @since 1.1.0 + */ override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -54,6 +96,15 @@ object EventBus { return compareTo(other) == 0 } + /** + * Returns a hash code value for this handler. + * + * The hash code is computed based on the priority, callable string representation, + * and the identity hash code of the instance. + * + * @return a hash code value for this handler + * @since 1.1.0 + */ override fun hashCode(): Int { var result = priority result = 31 * result + callable.toString().hashCode() @@ -61,11 +112,36 @@ object EventBus { return result } + /** + * Compares this handler with another handler for order. + * + * Handlers are ordered by priority first, then by callable string representation, + * and finally by instance identity hash code. + * + * @param other the handler to compare to + * @return a negative integer, zero, or a positive integer as this handler is less than, + * equal to, or greater than the specified handler + * @since 1.1.0 + */ override fun compareTo(other: EventHandler): Int { return compareTo(other.priority, other.callable, other.instance) } - fun compareTo(otherPriority: Int, otherCallable: KCallable<*>, otherInstance: Any): Int { + /** + * Compares this handler with the specified priority, callable, and instance. + * + * This method allows comparison without creating a temporary EventHandler object. + * Handlers are ordered by priority first, then by callable string representation, + * and finally by instance identity hash code. + * + * @param otherPriority the priority to compare to + * @param otherCallable the callable to compare to + * @param otherInstance the instance to compare to + * @return a negative integer, zero, or a positive integer as this handler is less than, + * equal to, or greater than the specified values + * @since 1.1.0 + */ + fun compareTo(otherPriority: Int, otherCallable: KCallable<*>, otherInstance: Any?): Int { val priorityComparison = priority.compareTo(otherPriority) if (priorityComparison != 0) return priorityComparison @@ -76,7 +152,7 @@ object EventBus { } } - private val eventHandlers: ConcurrentHashMap> = ConcurrentHashMap() + private val eventHandlers: ConcurrentHashMap> = ConcurrentHashMap() /** * Registers all event handlers in the given class for a specified instance. @@ -90,19 +166,16 @@ object EventBus { for (member in klass.members) { if (member.hasAnnotation()) { // include the default "this" parameter - if (member.parameters.size > 2 || member.parameters.size <= 1) + if (member.parameters.size !in 2..2) throw IllegalArgumentException("Member must have exactly one parameter " + "${member.javaClass.declaringClass?.kotlin?.simpleName}.${member.name}()") val priority = member.findAnnotation()?.priority?.ordinal ?: 0 - val targetEventId = member.parameters[1].type.classifier - ?.let { (it as? KClass<*>)?.qualifiedName } ?: continue + val targetEventId = member.parameters[1].type val newHandler = EventHandler(priority, member, instance) val eventHandlersForEventTarget = eventHandlers.computeIfAbsent(targetEventId) { ConcurrentSkipListSet() } - - logger.info("Registering event handler ${klass.simpleName}.${member.name}()") eventHandlersForEventTarget += newHandler } } @@ -127,20 +200,17 @@ object EventBus { for (member in klass.members) { if (member.hasAnnotation()) { // include the default "this" parameter - if (member.parameters.size > 2 || member.parameters.size <= 1) + if (member.parameters.size !in 2..2) throw IllegalArgumentException("Member must have exactly one parameter " + "${member.javaClass.declaringClass?.kotlin?.simpleName}.${member.name}()") val priority = member.findAnnotation()?.priority?.ordinal ?: 0 - val targetEventId = member.parameters[1].type.classifier - ?.let { (it as? KClass<*>)?.qualifiedName } ?: continue + val targetEventId = member.parameters[1].type val eventHandlersForEventTarget = eventHandlers[targetEventId] ?: continue val handler = eventHandlersForEventTarget.find { it.compareTo(priority, member, instance) == 0 } ?: continue - - logger.info("Unregistering event handler ${klass.simpleName}.${member.name}()") eventHandlersForEventTarget.remove(handler) } } @@ -173,7 +243,7 @@ object EventBus { * @since 1.0.0 */ fun post(event: Event): Any? { - val eventId = event::class.qualifiedName ?: return null + val eventId = event::class.starProjectedType val eventHandlers = eventHandlers[eventId] ?: return null for (handler in eventHandlers) { @@ -184,23 +254,32 @@ object EventBus { return event.callback() } - + /** + * Calls the handler with the given event. + * + * This method handles exceptions and logs them. Otherwise, the handler is called with the event. If the handler is no longer alive, + * it is removed from the event's handler list. If an exception occurs during the call, the handler is removed from the list. + * + * @param event The event to call the handler with. + * @param handler The handler to call. + * @since 1.0.0 + */ private fun callHandler(event: Event, handler: EventHandler) { val callable = handler.callable try { - if (callable.parameters[1].type.classifier?.let { (it as? KClass<*>)?.qualifiedName } != event::class.qualifiedName) { - logger.warning("Skipping event handler ${callable.name} due to class loader mismatch.") + if (!handler.isAlive) { + logger.warning("Removing dead event handler ${callable.name}.") + eventHandlers.values.forEach { it.remove(handler) } return } callable.call(handler.instance, event) } catch (e: InvocationTargetException) { val declaringClass = callable.javaClass.declaringClass?.name ?: callable.javaClass.name - logger.severe("Fatal Error during event in handler: $declaringClass.${callable.name}") + logger.severe("Fatal Error during event in handler: $declaringClass.${callable.name}, removing handler.") + eventHandlers.values.forEach { it.remove(handler) } e.printStackTrace() } - } - } \ No newline at end of file diff --git a/src/main/kotlin/net/integr/backbone/systems/hotloader/ScriptEngine.kt b/src/main/kotlin/net/integr/backbone/systems/hotloader/ScriptEngine.kt index 3d0f579..d916077 100644 --- a/src/main/kotlin/net/integr/backbone/systems/hotloader/ScriptEngine.kt +++ b/src/main/kotlin/net/integr/backbone/systems/hotloader/ScriptEngine.kt @@ -46,7 +46,7 @@ object ScriptEngine { for ((name, state) in ScriptStore.scripts) { try { - state.lifecycle.onUnload() + state.lifecycle.unload() state.enabled = false logger.info("Unloading script: $name") } catch (e: Exception) { @@ -84,7 +84,7 @@ object ScriptEngine { } try { - script.onUnload() + script.unload() state.enabled = false logger.info("Disabled script: '$name'") } catch (e: Exception) { @@ -118,7 +118,7 @@ object ScriptEngine { } try { - script.onLoad() + script.load() state.enabled = true logger.info("Enabled script: '$name'") } catch (e: Exception) { diff --git a/src/main/kotlin/net/integr/backbone/systems/hotloader/ScriptLinker.kt b/src/main/kotlin/net/integr/backbone/systems/hotloader/ScriptLinker.kt index dd2d8ca..9fdbfb9 100644 --- a/src/main/kotlin/net/integr/backbone/systems/hotloader/ScriptLinker.kt +++ b/src/main/kotlin/net/integr/backbone/systems/hotloader/ScriptLinker.kt @@ -203,7 +203,7 @@ object ScriptLinker { for ((name, state) in newScripts) { try { - state.lifecycle.onLoad() + state.lifecycle.load() state.enabled = true logger.info("[$name] Enabled script") } catch (e: Exception) { diff --git a/src/main/kotlin/net/integr/backbone/systems/hotloader/lifecycle/LifecycleSustainedState.kt b/src/main/kotlin/net/integr/backbone/systems/hotloader/lifecycle/LifecycleSustainedState.kt index c9c561c..29bbc49 100644 --- a/src/main/kotlin/net/integr/backbone/systems/hotloader/lifecycle/LifecycleSustainedState.kt +++ b/src/main/kotlin/net/integr/backbone/systems/hotloader/lifecycle/LifecycleSustainedState.kt @@ -102,6 +102,11 @@ class LifecycleSustainedState(private var value: T) : ReadWriteProperty(private var value: T) : ReadWriteProperty post -> unregister):") + println(" Iterations: $iterations") + println(" Total time: ${timeMs}ms") + println(" Avg per cycle: ${timeMs.toDouble() / iterations}ms") + } + + // ============================================ + // PRIORITY BENCHMARKS + // ============================================ + + @Test + fun benchmarkPrioritySorting() { + val listeners = List(20) { PriorityListener() } + listeners.forEach { EventBus.register(it) } + + val iterations = 10_000 + val timeMs = measureTimeMillis { + repeat(iterations) { + EventBus.post(SimpleEvent()) + EventBus.post(MediumEvent()) + EventBus.post(ComplexEvent()) + } + } + + println("Priority sorting overhead:") + println(" Listeners: ${listeners.size} (each with 3 different priority handlers)") + println(" Total events: ${iterations * 3}") + println(" Total time: ${timeMs}ms") + println(" Avg per event: ${timeMs.toDouble() / (iterations * 3)}ms") + } + + // ============================================ + // DEAD HANDLER BENCHMARKS + // ============================================ + + @Test + fun benchmarkDeadHandlerDetection() { + // Register handlers and let them become dead + val liveListeners = List(5) { SimpleListener() } + liveListeners.forEach { EventBus.register(it) } + + // Register dead handlers + repeat(10) { + val deadListener = SimpleListener() + EventBus.register(deadListener) + // Let it go out of scope + } + + // Force GC + System.gc() + Thread.sleep(100) + System.gc() + Thread.sleep(100) + + val iterations = 10_000 + val timeMs = measureTimeMillis { + repeat(iterations) { + EventBus.post(SimpleEvent()) + } + } + + println("Dead handler detection overhead:") + println(" Live handlers: ${liveListeners.size}") + println(" Dead handlers: ~10 (GC-dependent)") + println(" Iterations: $iterations") + println(" Total time: ${timeMs}ms") + println(" Avg per event: ${timeMs.toDouble() / iterations}ms") + println(" Total live calls: ${liveListeners.sumOf { it.callCount }}") + } + + @Test + fun benchmarkDeadHandlerCleanup() { + // Create many dead handlers + repeat(100) { + val listener = SimpleListener() + EventBus.register(listener) + } + + // Force GC + System.gc() + Thread.sleep(100) + System.gc() + Thread.sleep(100) + + // First post triggers cleanup + val firstPostTime = measureNanoTime { + EventBus.post(SimpleEvent()) + } + + // Subsequent posts should be faster + val subsequentTimes = mutableListOf() + repeat(1000) { + val time = measureNanoTime { + EventBus.post(SimpleEvent()) + } + subsequentTimes.add(time) + } + + val avgSubsequent = subsequentTimes.average() + + println("Dead handler cleanup impact:") + println(" First post (with cleanup): ${firstPostTime / 1_000}µs") + println(" Avg subsequent posts: ${String.format("%.2f", avgSubsequent / 1_000)}µs") + println(" Cleanup overhead: ${String.format("%.2f", (firstPostTime - avgSubsequent) / 1_000)}µs") + } + + // ============================================ + // REAL-WORLD SCENARIO BENCHMARKS + // ============================================ + + @Test + fun benchmarkMixedWorkload() { + // Simulate a realistic mixed workload + val simpleListeners = List(10) { SimpleListener() } + val multiListeners = List(5) { MultiHandlerListener() } + val heavyListeners = List(3) { HeavyListener() } + + simpleListeners.forEach { EventBus.register(it) } + multiListeners.forEach { EventBus.register(it) } + heavyListeners.forEach { EventBus.register(it) } + + val iterations = 5_000 + val timeMs = measureTimeMillis { + repeat(iterations) { + EventBus.post(SimpleEvent()) + if (it % 2 == 0) EventBus.post(MediumEvent()) + if (it % 5 == 0) EventBus.post(ComplexEvent()) + } + } + + val totalEvents = iterations + (iterations / 2) + (iterations / 5) + + println("Mixed workload simulation:") + println(" Simple listeners: ${simpleListeners.size}") + println(" Multi listeners: ${multiListeners.size}") + println(" Heavy listeners: ${heavyListeners.size}") + println(" Total events posted: $totalEvents") + println(" Total time: ${timeMs}ms") + println(" Avg per event: ${timeMs.toDouble() / totalEvents}ms") + println(" Events/sec: ${(totalEvents / (timeMs.toDouble() / 1000)).toLong()}") + } + + @Test + fun benchmarkHighFrequencyBursts() { + val listener = SimpleListener() + EventBus.register(listener) + + val bursts = 10 + val eventsPerBurst = 10_000 + val results = mutableListOf() + + println("High-frequency burst test:") + println(" Bursts: $bursts") + println(" Events per burst: $eventsPerBurst") + println() + + repeat(bursts) { burstNum -> + val timeMs = measureTimeMillis { + repeat(eventsPerBurst) { + EventBus.post(SimpleEvent()) + } + } + results.add(timeMs) + println(" Burst ${burstNum + 1}: ${timeMs}ms, ${(eventsPerBurst / (timeMs.toDouble() / 1000)).toLong()} events/sec") + } + + println() + println(" Avg burst time: ${results.average()}ms") + println(" Min: ${results.minOrNull()}ms, Max: ${results.maxOrNull()}ms") + println(" Total calls: ${listener.callCount}") + } + + // ============================================ + // MEMORY AND OVERHEAD BENCHMARKS + // ============================================ + + @Test + fun benchmarkMemoryOverhead() { + val runtime = Runtime.getRuntime() + + // Baseline memory + System.gc() + Thread.sleep(100) + val baselineMemory = runtime.totalMemory() - runtime.freeMemory() + + // Register many handlers + val listeners = List(1000) { MultiHandlerListener() } + listeners.forEach { EventBus.register(it) } + + System.gc() + Thread.sleep(100) + val afterRegistrationMemory = runtime.totalMemory() - runtime.freeMemory() + + val memoryUsed = (afterRegistrationMemory - baselineMemory) / 1024 / 1024 + + println("Memory overhead estimation:") + println(" Baseline memory: ${baselineMemory / 1024 / 1024}MB") + println(" After registering 1000 listeners: ${afterRegistrationMemory / 1024 / 1024}MB") + println(" Memory used by handlers: ~${memoryUsed}MB") + println(" Avg per listener: ~${memoryUsed.toDouble() / 1000}MB") + } + + @Test + fun benchmarkConcurrentPosting() { + val listener = SimpleListener() + EventBus.register(listener) + + val threads = 4 + val iterationsPerThread = 25_000 + + val timeMs = measureTimeMillis { + val threadList = List(threads) { + Thread { + repeat(iterationsPerThread) { + EventBus.post(SimpleEvent()) + } + } + } + + threadList.forEach { it.start() } + threadList.forEach { it.join() } + } + + val totalEvents = threads * iterationsPerThread + + println("Concurrent posting test:") + println(" Threads: $threads") + println(" Events per thread: $iterationsPerThread") + println(" Total events: $totalEvents") + println(" Total time: ${timeMs}ms") + println(" Events/sec: ${(totalEvents / (timeMs.toDouble() / 1000)).toLong()}") + println(" Handler calls: ${listener.callCount}") + } +} diff --git a/src/test/kotlin/net/integr/backbone/systems/event/EventBusTest.kt b/src/test/kotlin/net/integr/backbone/systems/event/EventBusTest.kt index f0d42e9..054021d 100644 --- a/src/test/kotlin/net/integr/backbone/systems/event/EventBusTest.kt +++ b/src/test/kotlin/net/integr/backbone/systems/event/EventBusTest.kt @@ -173,6 +173,243 @@ class EventBusTest { assertEquals(4, callCount, "All handlers across all instances should have been called") } + @Test + fun testDeadHandlerAutoRemoval() { + var callCount = 0 + + class TestListener { + @BackboneEventHandler + fun onEvent(event: TestEvent) { + callCount++ + } + } + + // Create listener and register it + var listener: TestListener? = TestListener() + EventBus.register(listener!!) + + // First post should work + EventBus.post(TestEvent()) + assertEquals(1, callCount, "Handler should have been called") + + // Clear the reference and force garbage collection + listener = null + System.gc() + Thread.sleep(100) // Give GC time to run + System.gc() + Thread.sleep(100) + + // Try to post again - dead handler should be detected and removed + EventBus.post(TestEvent()) + + // Handler should not have been called again (still 1, not 2) + // Note: This test may be flaky depending on GC behavior + // The important thing is no exception is thrown + } + + @Test + fun testDeadHandlerRemovedOnInvocation() { + var callCount = 0 + + class TestListener { + @BackboneEventHandler + fun onEvent(event: TestEvent) { + callCount++ + } + } + + // Create listener, register it, then clear reference + var listener: TestListener? = TestListener() + EventBus.register(listener!!) + listener = null + + // Force GC + System.gc() + Thread.sleep(100) + System.gc() + Thread.sleep(100) + + // Post event - should detect dead handler + EventBus.post(TestEvent()) + + // Post again - dead handler should have been removed from first post + EventBus.post(TestEvent()) + + // No exceptions should be thrown + assertTrue(true, "Dead handlers should be removed gracefully") + } + + @Test + fun testHandlerExceptionRemovesHandler() { + var callCount = 0 + + class TestListener { + @BackboneEventHandler + fun onThrowingEvent(event: TestEvent) { + callCount++ + throw RuntimeException("Intentional exception") + } + } + + val listener = TestListener() + EventBus.register(listener) + + // First post should throw and increment count + EventBus.post(TestEvent()) + assertEquals(1, callCount, "Handler should have been called once") + + // Second post should not call handler (it was removed due to exception) + EventBus.post(TestEvent()) + assertEquals(1, callCount, "Handler should have been removed after exception") + } + + @Test + fun testMixedAliveAndDeadHandlers() { + var aliveCallCount = 0 + var deadCallCount = 0 + + class AliveListener { + @BackboneEventHandler + fun onEvent(event: TestEvent) { + aliveCallCount++ + } + } + + class DeadListener { + @BackboneEventHandler + fun onEvent(event: TestEvent) { + deadCallCount++ + } + } + + // Register alive listener (keep strong reference) + val aliveListener = AliveListener() + EventBus.register(aliveListener) + + // Register dead listener (no strong reference) + var deadListener: DeadListener? = DeadListener() + EventBus.register(deadListener!!) + deadListener = null + + // Force GC + System.gc() + Thread.sleep(100) + System.gc() + Thread.sleep(100) + + // Post event + EventBus.post(TestEvent()) + + // Alive handler should be called + assertEquals(1, aliveCallCount, "Alive handler should have been called") + + // Post again to ensure system still works after cleanup + EventBus.post(TestEvent()) + assertEquals(2, aliveCallCount, "Alive handler should have been called again") + } + + @Test + fun testWeakReferenceDoesNotPreventNormalOperation() { + var callCount = 0 + + class TestListener { + @BackboneEventHandler + fun onEvent(event: TestEvent) { + callCount++ + } + } + + // Keep strong reference to listener + val listener = TestListener() + EventBus.register(listener) + + // Post multiple times + repeat(5) { + EventBus.post(TestEvent()) + } + + assertEquals(5, callCount, "Handler should be called all 5 times when instance is alive") + } + + @Test + fun testDeadHandlerWithMultipleEvents() { + class AnotherTestEvent : Event() + + var testEventCallCount = 0 + var anotherEventCallCount = 0 + + class TestListener { + @BackboneEventHandler + fun onTestEvent(event: TestEvent) { + testEventCallCount++ + } + + @BackboneEventHandler + fun onAnotherEvent(event: AnotherTestEvent) { + anotherEventCallCount++ + } + } + + // Register and then clear reference + var listener: TestListener? = TestListener() + EventBus.register(listener!!) + listener = null + + // Force GC + System.gc() + Thread.sleep(100) + System.gc() + Thread.sleep(100) + + // Post both event types - should handle dead handlers gracefully + EventBus.post(TestEvent()) + EventBus.post(AnotherTestEvent()) + + // No exceptions should be thrown + assertTrue(true, "Dead handlers for multiple event types should be handled gracefully") + } + + @Test + fun testDeadHandlerDoesNotAffectOtherInstances() { + var instance1Calls = 0 + var instance2Calls = 0 + + class TestListener { + var id: Int = 0 + + @BackboneEventHandler + fun onEvent(event: TestEvent) { + if (id == 1) instance1Calls++ + else if (id == 2) instance2Calls++ + } + } + + // Register first instance and keep it alive + val listener1 = TestListener().apply { id = 1 } + EventBus.register(listener1) + + // Register second instance and let it die + var listener2: TestListener? = TestListener().apply { id = 2 } + EventBus.register(listener2!!) + listener2 = null + + // Force GC + System.gc() + Thread.sleep(100) + System.gc() + Thread.sleep(100) + + // Post event + EventBus.post(TestEvent()) + + // First instance should be called + assertEquals(1, instance1Calls, "Alive instance should be called") + + // Post again to verify first instance still works + EventBus.post(TestEvent()) + assertEquals(2, instance1Calls, "Alive instance should continue working") + } + @AfterEach fun tearDown() { EventBus.clear() From f3e276c8b81fcef2342c66bdde276164b9ef8e00 Mon Sep 17 00:00:00 2001 From: integr-dev Date: Wed, 4 Mar 2026 18:05:19 +0100 Subject: [PATCH 2/3] Bump version to 1.3.0. Add cancelable events, command help system, and improved scripting features. Bump "tools.jackson.core:jackson-databind:3.1.0" --- .gitignore | 1 + README.md | 33 ++- build.gradle.kts | 4 +- .../kotlin/net/integr/backbone/Backbone.kt | 9 +- .../backbone/commands/BackboneCommand.kt | 49 ++++- .../commands/arguments/CommandArgument.kt | 78 +++++++ .../backbone/systems/command/Command.kt | 67 +++++- .../systems/command/CommandHandler.kt | 51 ++++- .../systems/command/argument/Argument.kt | 12 + .../backbone/systems/command/help/HelpNode.kt | 160 ++++++++++++++ .../integr/backbone/systems/event/Event.kt | 75 +++++-- .../integr/backbone/systems/event/EventBus.kt | 11 +- .../integr/backbone/systems/text/Alphabet.kt | 22 +- .../integr/backbone/systems/text/Component.kt | 16 +- .../text/alphabets/BoldSmallAlphabet.kt | 23 +- .../backbone/text/alphabets/TinyAlphabet.kt | 25 +++ .../systems/event/EventBusBenchmark.kt | 123 ++++------- .../backbone/systems/event/EventBusTest.kt | 206 ++++++++++++++++-- 18 files changed, 779 insertions(+), 186 deletions(-) create mode 100644 src/main/kotlin/net/integr/backbone/commands/arguments/CommandArgument.kt create mode 100644 src/main/kotlin/net/integr/backbone/systems/command/help/HelpNode.kt create mode 100644 src/main/kotlin/net/integr/backbone/text/alphabets/TinyAlphabet.kt diff --git a/.gitignore b/.gitignore index ab84b87..fb70c28 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /.gradle/ /run/ /.kotlin/ +/logs/ diff --git a/README.md b/README.md index c0069ed..38c6335 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ + logo.png # Backbone @@ -7,7 +8,7 @@ ![GitHub last commit](https://img.shields.io/github/last-commit/integr-dev/backbone) [![GitHub license](https://img.shields.io/github/license/integr-dev/backbone)](https://github.com/integr-dev/backbone/blob/master/LICENSE) -Backbone is a powerful and flexible plugin for Spigot-based Minecraft servers, designed to supercharge server customization. Its core philosophy is to enable server administrators and developers to write, test, and update server logic on a live server without requiring restarts, dramatically accelerating the development lifecycle. +Backbone is a powerful and flexible plugin for Spigot-based Minecraft servers, designed to supercharge server customization. Its core philosophy is to enable server administrators and developers to write, test, and update server logic on a live server without requiring restarts, dramatically speeding up the development lifecycle. Whether you're a server administrator looking to add custom features with simple scripts or a developer prototyping new ideas, Backbone provides the tools you need to be more productive and creative. @@ -17,7 +18,7 @@ Whether you're a server administrator looking to add custom features with simple - **Advanced Scripting:** Go beyond simple scripts with support for inter-script imports, Maven dependencies, and custom compiler options. - **Event System:** A custom event bus that complements Bukkit's event system, offering more control and flexibility within your scripts. - **Command Framework:** A simple yet powerful command system to create custom commands directly from your scripts. -- **Storage Abstraction:** Easily manage data with a flexible storage system that supports SQLite databases and typed configuration files. +- **Storage Abstraction:** Manage data with a flexible storage system that supports SQLite databases and typed configuration files. - **GUI Framework:** A declarative GUI framework for creating complex and interactive inventories from your scripts. - **Text Formatting:** A flexible text formatting system with support for custom alphabets and color codes. - **Entity Framework:** Custom entity utility for adding custom entities via the goals api. @@ -26,11 +27,11 @@ Whether you're a server administrator looking to add custom features with simple ## Getting Started -Getting started with Backbone is simple. The primary way to use Backbone is by installing it as a plugin and then creating your own custom features through its scripting engine. +Getting started with Backbone is straightforward. The primary way to use Backbone is by installing it as a plugin and then creating your own custom features through its scripting engine. ### Requirements - Minecraft Java Edition Server version 1.21 or higher. -- [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.62/) (optional, for placeholder support). +- [PlaceholderAPI](https://modrinth.com/plugin/placeholderapi) (optional, for placeholder support). ### Installation 1. **Download:** Download the latest release from the [official releases page](https://github.com/integr-dev/backbone/releases). @@ -186,7 +187,7 @@ This will create directories at `storage/mystorage/` and `config/myconfig/` in y #### Configuration -You can easily manage typed configuration files. Backbone handles the serialization and deserialization of your data classes automatically. +You can manage typed configuration files. Backbone handles the serialization and deserialization of your data classes automatically. First, define a serializable data class for your configuration: @@ -215,7 +216,6 @@ configHandler.writeState(currentConfig.copy(settingB = 20)) #### Databases Backbone provides a simple and efficient way to work with SQLite databases from within your scripts. - ```kotlin // Get a connection to a database file named 'playerdata.db' val dbConnection = myScriptStorage.database("playerdata.db") @@ -239,7 +239,7 @@ dbConnection.useConnection { ### Custom Events -Backbone's event system allows you to create and listen for custom events, giving you more control over your script's behavior. +Backbone's event system allows you to create and listen to custom events, giving you more control over your script's behavior. ```kotlin // Define a custom event @@ -250,7 +250,7 @@ class MyCustomEvent(val message: String) : Event() @BackboneEventHandler(EventPriority.THREE_BEFORE) fun onMyCustomEvent(event: MyCustomEvent) { println("Received custom event: ${event.message}") - event.setCallback("yay!") + event.callback = "yay!" } // Fire the custom event from anywhere in your code @@ -279,14 +279,14 @@ object MyCommand : Command("mycommand", "My first command") { } override suspend fun exec(ctx: Execution) { - // Require a permission for this command + // Require permission for this command ctx.requirePermission(perm.derive("mycommand")) // "myplugin.mycommand" val text = ctx.get("text") ctx.respond("Hello ${ctx.sender.name}: $text") - // To affect server state, dispatch to the main thread for the next tick. + // To affect the server state, dispatch to the main thread for the next tick. Backbone.dispatchMain { val player = ctx.getPlayer() // Get the sender as a player (and require it to be one) player.world.spawnEntity(player.location, EntityType.BEE) @@ -382,7 +382,7 @@ Backbone allows you to create custom entities with unique AI goals. // Define a custom entity that is a non-moving zombie object GuardEntity : CustomEntity("guard", EntityType.ZOMBIE) { override fun prepare(mob: Zombie) { - // Set up for example armor + // Set up, for example, armor } override fun setupGoals(mob: Zombie) { @@ -398,7 +398,7 @@ override fun onLoad() { Backbone.Handlers.ENTITY.register(GuardEntity) } -// You can then spawn the entity for example using a command +// You can then spawn the entity, for example, using a command // In a command's exec method: GuardEntity.spawn(ctx.getPlayer().location, ctx.getPlayer().world) ``` @@ -467,7 +467,7 @@ component { #### Command Feedback Format -You can create a custom `CommandFeedbackFormat` to change how command responses are displayed. Or simply inherit from it to unlock even more customisation via the component system. +You can create a custom `CommandFeedbackFormat` to change how command responses are displayed. Or inherit from it to unlock even more customization via the component system. ```kotlin val myFormat = CommandFeedbackFormat("MyPlugin", Color.RED) @@ -486,12 +486,7 @@ You can create your own custom alphabets by implementing the `Alphabet` interfac ```kotlin object MyAlphabet : Alphabet { - const val ALPHABET = "..." // Your custom alphabet characters - - override fun encode(str: String): String { - // Your encoding logic here - return "encoded_string" - } + override val alphabet = "..." // Your custom alphabet characters } ``` diff --git a/build.gradle.kts b/build.gradle.kts index 33d1581..3bbe839 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "net.integr" -version = "1.2.0" +version = "1.3.0" repositories { mavenCentral() @@ -41,7 +41,7 @@ dependencies { implementation("org.apache.ivy:ivy:2.5.2") - implementation("tools.jackson.core:jackson-databind:3.0.4") + implementation("tools.jackson.core:jackson-databind:3.1.0") implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.0.4") implementation("tools.jackson.module:jackson-module-kotlin:3.0.4") diff --git a/src/main/kotlin/net/integr/backbone/Backbone.kt b/src/main/kotlin/net/integr/backbone/Backbone.kt index bf6ab46..2f01f02 100644 --- a/src/main/kotlin/net/integr/backbone/Backbone.kt +++ b/src/main/kotlin/net/integr/backbone/Backbone.kt @@ -36,7 +36,7 @@ import org.jetbrains.annotations.ApiStatus * @since 1.0.0 */ object Backbone { - //TODO: dialogues, command help builder + //TODO: dialogues /** * Backbones internal storage pool. **Important:** Do not use this. * Create a new pool instead: @@ -109,12 +109,15 @@ object Backbone { /** * The internally checked plugin instance. - * Null if we are in a testing environment. + * Null if we are not in a plugin environment. + * + * This is used to avoid null checks in the codebase. + * If you are not in a plugin environment, you can safely ignore this. * * @since 1.0.0 */ private val pluginInternal: JavaPlugin? by lazy { - Utils.tryOrNull { JavaPlugin.getPlugin(BackboneServer::class.java) } // For testing purposes + Utils.tryOrNull { JavaPlugin.getPlugin(BackboneServer::class.java) } // For testing purposes we allow null here } /** diff --git a/src/main/kotlin/net/integr/backbone/commands/BackboneCommand.kt b/src/main/kotlin/net/integr/backbone/commands/BackboneCommand.kt index 7ff4090..0714fa1 100644 --- a/src/main/kotlin/net/integr/backbone/commands/BackboneCommand.kt +++ b/src/main/kotlin/net/integr/backbone/commands/BackboneCommand.kt @@ -14,11 +14,14 @@ package net.integr.backbone.commands import net.integr.backbone.Backbone +import net.integr.backbone.commands.arguments.ValidatedArgument.ValidationResult.Companion.fail +import net.integr.backbone.commands.arguments.commandArgument import net.integr.backbone.commands.arguments.customEntityArgument import net.integr.backbone.commands.arguments.customItemArgument import net.integr.backbone.commands.arguments.scriptArgument import net.integr.backbone.commands.arguments.stringArgument import net.integr.backbone.systems.command.Command +import net.integr.backbone.systems.command.CommandHandler import net.integr.backbone.systems.command.Execution import net.integr.backbone.systems.entity.EntityHandler import net.integr.backbone.systems.hotloader.ScriptEngine @@ -37,18 +40,42 @@ import java.awt.Color * * @since 1.0.0 */ -object BackboneCommand : Command("backbone", "Base command for Backbone", listOf("bb")) { +object BackboneCommand : Command("backbone", "Base command for backbone", listOf("bb")) { val perm = Backbone.ROOT_PERMISSION.derive("command") override fun onBuild() { - subCommands(Scripting, Item, Entity) + subCommands(Scripting, Item, Entity, Help) } override suspend fun exec(ctx: Execution) { ctx.respond("Backbone v${Backbone.VERSION}") } - object Scripting : Command("scripting", "Commands for Backbone scripting system") { + object Help : Command("help", "Shows help for a specific command") { + val helpPerm = perm.derive("help") + + override fun onBuild() { + arguments( + commandArgument("command", "The command to get help for") + ) + } + + override suspend fun exec(ctx: Execution) { + ctx.requirePermission(helpPerm) + + val command = ctx.get("command") + val help = CommandHandler.getHelp(command) + + if (help == null) { + fail("No help found for command: $command") + } else { + ctx.respond("Displaying help for: $command") + ctx.respondComponent(help.buildComponent()) + } + } + } + + object Scripting : Command("scripting", "Commands for the backbone scripting system") { val scriptingPerm = perm.derive("scripting") override fun onBuild() { @@ -84,7 +111,7 @@ object BackboneCommand : Command("backbone", "Base command for Backbone", listOf } } - object Reload : Command("reload", "Reload all Backbone scripts") { + object Reload : Command("reload", "Reload all backbone scripts") { val scriptingReloadPerm = scriptingPerm.derive("reload") override suspend fun exec(ctx: Execution) { @@ -100,7 +127,7 @@ object BackboneCommand : Command("backbone", "Base command for Backbone", listOf } } - object Enable : Command("enable", "Enable a Backbone script") { + object Enable : Command("enable", "Enable a backbone script") { val scriptingEnablePerm = scriptingPerm.derive("enable") override fun onBuild() { @@ -125,7 +152,7 @@ object BackboneCommand : Command("backbone", "Base command for Backbone", listOf } } - object Disable : Command("disable", "Disable a Backbone script") { + object Disable : Command("disable", "Disable a backbone script") { val scriptingDisablePerm = scriptingPerm.derive("disable") override fun onBuild() { @@ -180,7 +207,7 @@ object BackboneCommand : Command("backbone", "Base command for Backbone", listOf } } - object Item : Command("item", "Commands for Backbone item system") { + object Item : Command("item", "Commands for the backbone item system") { val itemPerm = perm.derive("item") override fun onBuild() { @@ -200,12 +227,12 @@ object BackboneCommand : Command("backbone", "Base command for Backbone", listOf } } - object Give : Command("give", "Gives a custom item") { + object Give : Command("give", "Gives you a custom item") { val itemGivePerm = itemPerm.derive("give") override fun onBuild() { arguments( - customItemArgument("item", "The custom item to give") + customItemArgument("item", "The custom item to give you") ) } @@ -249,7 +276,7 @@ object BackboneCommand : Command("backbone", "Base command for Backbone", listOf } } - object Read : Command("read", "Reads all meta tags from an item.") { + object Read : Command("read", "Reads all meta tags from the held item.") { val itemReadPerm = itemPerm.derive("read") override suspend fun exec(ctx: Execution) { @@ -281,7 +308,7 @@ object BackboneCommand : Command("backbone", "Base command for Backbone", listOf } } - object Entity : Command("entity", "Commands for Backbone entity system") { + object Entity : Command("entity", "Commands for the backbone entity system") { val entityPerm = perm.derive("entity") override fun onBuild() { diff --git a/src/main/kotlin/net/integr/backbone/commands/arguments/CommandArgument.kt b/src/main/kotlin/net/integr/backbone/commands/arguments/CommandArgument.kt new file mode 100644 index 0000000..6d18a8e --- /dev/null +++ b/src/main/kotlin/net/integr/backbone/commands/arguments/CommandArgument.kt @@ -0,0 +1,78 @@ +/* + * Copyright © 2026 Integr + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.integr.backbone.commands.arguments + +import net.integr.backbone.systems.command.CommandArgumentException +import net.integr.backbone.systems.command.CommandHandler +import net.integr.backbone.systems.command.argument.Argument + +/** + * A command argument that parses a command. + * + * This argument accepts any valid custom item name as input. + * It provides completions for all registered custom items. + * + * @param name The name of the argument. + * @param description A brief description of the argument's purpose. + * @since 1.3.0 + */ +fun commandArgument(name: String, description: String): Argument { + return CommandArgument(name, description) +} + +/** + * A command argument that parses a command. + * + * This argument accepts any valid custom item name as input. + * It provides completions for all registered custom items. + * + * @param name The name of the argument. + * @param description A brief description of the argument's purpose. + * @since 1.0.0 + */ +class CommandArgument(name: String, description: String) : Argument(name, description) { + override fun getCompletions(current: ArgumentInput): CompletionResult { + val commands = CommandHandler.commands.keys + val isQuoted = current.value.startsWith("\"") + + val arg = if (isQuoted) current.getNextGreedyWithBoundChar('"') else current.getNextSingle() + + val hasClosingQuote = isQuoted && arg.found + + val itemMap = commands.map { it + "\"" }.toMutableList() + + return if (isQuoted && !hasClosingQuote) { + CompletionResult(itemMap, arg.end) + } else { + CompletionResult(if (arg.text.isBlank()) mutableListOf("<$name:command>", *commands.toTypedArray()) else commands.toMutableList(), arg.end) + } + } + + override fun parse(current: ArgumentInput): ParseResult { + val commands = CommandHandler.commands.keys + val isQuoted = current.value.startsWith("\"") + val arg = if (isQuoted) current.getNextGreedyWithBoundChar('"') else current.getNextSingle() + + val text = if (isQuoted) { + if (!arg.found) throw CommandArgumentException("Argument '$name' is missing a closing quotation mark.") + arg.text.substring(1, arg.text.length - 1) + } else { + arg.text + } + + if (!commands.contains(text)) throw CommandArgumentException("Argument '$name' is not a valid command.") + + return ParseResult(text, arg.end) + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/integr/backbone/systems/command/Command.kt b/src/main/kotlin/net/integr/backbone/systems/command/Command.kt index f4744bc..76c0df0 100644 --- a/src/main/kotlin/net/integr/backbone/systems/command/Command.kt +++ b/src/main/kotlin/net/integr/backbone/systems/command/Command.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.launch import net.integr.backbone.Backbone import net.integr.backbone.systems.command.argument.ArgumentChain import net.integr.backbone.systems.command.argument.Argument +import net.integr.backbone.systems.command.help.HelpNode import net.integr.backbone.text.formats.CommandFeedbackFormat import org.bukkit.command.CommandSender import org.bukkit.command.defaults.BukkitCommand @@ -35,11 +36,46 @@ import org.jetbrains.annotations.ApiStatus abstract class Command(name: String, description: String, aliases: List = listOf(), val format: CommandFeedbackFormat = CommandHandler.defaultFeedbackFormat) : BukkitCommand(name, description, "See backbone help", aliases) { private val logger = Backbone.LOGGER.derive("command") - private val subCommands = mutableListOf() + /** + * The list of sub-commands registered for this command. + * @since 1.0.0 + */ + @ApiStatus.Internal + val subCommands = mutableListOf() + + /** + * The list of arguments registered for this command. + * @since 1.0.0 + */ + @ApiStatus.Internal private val arguments = mutableListOf>() private var subCommandNames: List = listOf() + /** + * The parent command of this command, if any. + * @since 1.3.0 + */ + var parent: Command? = null + private set + + /** + * The full name of this command, including any parent commands. + * @since 1.3.0 + */ + var fullName: String? = null + private set + + /** + * The help node for this command. + * + * Help nodes are used to display the structure of the command tree. + * + * @since 1.3.0 + */ + var helpNode: HelpNode? = null + private set + /** * Registers one or more sub-commands to this command. * @@ -48,6 +84,7 @@ abstract class Command(name: String, description: String, aliases: List */ fun subCommands(vararg commands: Command) { commands.forEach { + it.parent = this it.build() } @@ -64,13 +101,41 @@ abstract class Command(name: String, description: String, aliases: List this.arguments.addAll(arguments) } + /** + * Computes the help node for this command. + * + * This method recursively builds the help node for all sub-commands and their arguments. + * It is used by the help command to display the command structure. + * + * @since 1.3.0 + */ + private fun computeHelpNode() { + val subCommandNodes = subCommands.map { it.helpNode ?: throw IllegalStateException("Subcommand ${it.name} has not been built yet.") } + + val contents = mutableListOf() + + contents.add(HelpNode.Content(description, HelpNode.Content.Type.TEXT)) + + if (arguments.isNotEmpty()) contents.add(HelpNode.Content("Arguments", HelpNode.Content.Type.TITLE)) + arguments.forEach { contents.add(it.getHelpText()) } + + if (aliases.isNotEmpty()) contents.add(HelpNode.Content("Aliases", HelpNode.Content.Type.TITLE)) + aliases.forEach { contents.add(HelpNode.Content(it, HelpNode.Content.Type.LIST)) } + + helpNode = HelpNode(name, contents, subCommandNodes) + } + /** * Builds the command and its sub-commands. * @since 1.0.0 */ @ApiStatus.Internal fun build() { + fullName = if (parent == null) name else "${parent!!.fullName}.$name" + onBuild() + computeHelpNode() + subCommandNames = subCommands.map { it.name } + subCommands.flatMap { it.aliases } } diff --git a/src/main/kotlin/net/integr/backbone/systems/command/CommandHandler.kt b/src/main/kotlin/net/integr/backbone/systems/command/CommandHandler.kt index e4f132d..e90e36a 100644 --- a/src/main/kotlin/net/integr/backbone/systems/command/CommandHandler.kt +++ b/src/main/kotlin/net/integr/backbone/systems/command/CommandHandler.kt @@ -16,11 +16,13 @@ package net.integr.backbone.systems.command import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import net.integr.backbone.Backbone +import net.integr.backbone.systems.command.help.HelpNode import net.integr.backbone.text.formats.CommandFeedbackFormat import org.bukkit.command.CommandMap import org.jetbrains.annotations.ApiStatus import java.awt.Color import java.lang.reflect.Field +import java.util.concurrent.ConcurrentHashMap /** * Handles the registration and unregistration of commands. @@ -44,7 +46,7 @@ object CommandHandler { @ApiStatus.Internal val coroutineScope = CoroutineScope(Dispatchers.IO) - private val map: CommandMap by lazy { + private val bukkitMap: CommandMap by lazy { val bukkitCommandMap: Field = Backbone.SERVER.javaClass.getDeclaredField("commandMap") bukkitCommandMap.isAccessible = true val map = bukkitCommandMap.get(Backbone.SERVER) as CommandMap @@ -52,6 +54,26 @@ object CommandHandler { map } + /** + * The map of registered commands by their full name. + * + * @since 1.3.0 + */ + @ApiStatus.Internal + val commands: ConcurrentHashMap = ConcurrentHashMap() + + /** + * Get the help node for a command. + * + * @param command The command to get the help node for. + * @return The help node for the command, or null if not found. + * @since 1.3.0 + */ + fun getHelp(command: String): HelpNode? { + val found = commands[command] ?: return null + return found.helpNode + } + /** * Register a command to the server. * @@ -61,12 +83,31 @@ object CommandHandler { */ fun register(command: Command, prefix: String = "backbone") { command.build() - map.register(prefix, command) + push(command) + + bukkitMap.register(prefix, command) Backbone.SERVER.onlinePlayers.forEach { it.updateCommands() } } + private fun push(command: Command) { + commands[command.fullName!!] = command + command.subCommands.forEach { + push(it) + } + } + + private fun pop(command: Command) { + command.subCommands.forEach { + pop(it) + } + commands.remove(command.fullName) + command.aliases.forEach { alias -> + commands.remove(alias) + } + } + /** * Unregister a command from the server. * @@ -75,8 +116,8 @@ object CommandHandler { * @since 1.0.0 */ fun unregister(command: Command, prefix: String = "backbone") { + commands.remove(command.fullName) unregisterCommand(command.name, prefix) - Backbone.SERVER.onlinePlayers.forEach { it.updateCommands() } @@ -84,9 +125,9 @@ object CommandHandler { private fun unregisterCommand(commandName: String, prefix: String = "backbone") { try { - val knownCommandsField = map.javaClass.getSuperclass().getDeclaredField("knownCommands") + val knownCommandsField = bukkitMap.javaClass.getSuperclass().getDeclaredField("knownCommands") knownCommandsField.setAccessible(true) - val knownCommands = knownCommandsField.get(map) as MutableMap<*, *> + val knownCommands = knownCommandsField.get(bukkitMap) as MutableMap<*, *> knownCommands.remove(commandName) knownCommands.remove("$prefix:$commandName") diff --git a/src/main/kotlin/net/integr/backbone/systems/command/argument/Argument.kt b/src/main/kotlin/net/integr/backbone/systems/command/argument/Argument.kt index f78e118..43f3578 100644 --- a/src/main/kotlin/net/integr/backbone/systems/command/argument/Argument.kt +++ b/src/main/kotlin/net/integr/backbone/systems/command/argument/Argument.kt @@ -13,6 +13,8 @@ package net.integr.backbone.systems.command.argument +import net.integr.backbone.systems.command.help.HelpNode + /** * Represents a command argument. * @@ -42,6 +44,16 @@ abstract class Argument(val name: String, val description: String) { */ abstract fun parse(current: ArgumentInput): ParseResult + /** + * Returns a string representation of the argument's help text. + * @return A string containing the argument's name and description. + * + * @since 1.3.0 + */ + fun getHelpText(): HelpNode.Content { + return HelpNode.Content(" $name - $description", HelpNode.Content.Type.LIST) + } + /** * Represents a finished completion. * diff --git a/src/main/kotlin/net/integr/backbone/systems/command/help/HelpNode.kt b/src/main/kotlin/net/integr/backbone/systems/command/help/HelpNode.kt new file mode 100644 index 0000000..30d3c98 --- /dev/null +++ b/src/main/kotlin/net/integr/backbone/systems/command/help/HelpNode.kt @@ -0,0 +1,160 @@ +/* + * Copyright © 2026 Integr + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.integr.backbone.systems.command.help + +import net.integr.backbone.systems.text.component +import net.kyori.adventure.text.Component +import java.awt.Color + +/** + * Represents a hierarchical node in the command help system. + * + * This class builds a tree structure that displays command information in a formatted, visually organized manner. + * Each node can contain content (descriptions, arguments, aliases) and child nodes (subcommands). + * The tree structure is rendered with proper indentation, tree branches, and color coding for easy readability. + * + * @param title The title of this help node (e.g., command name). + * @param contents A list of content items to display under the title. + * @param children A list of child nodes representing subcommands. + * @since 1.3.0 + */ +class HelpNode(val title: String, val contents: List, val children: List) { + + /** + * Builds a formatted text component representation of this help node and its entire tree. + * + * This method recursively renders the help node as an Adventure text component with proper tree structure, + * indentation, color coding, and formatting. The tree is displayed with branch characters (├, └) and + * vertical lines (│) to show the hierarchy. Content sections are separated with visual separators (─). + * + * @param prefix The prefix string for indentation of nested nodes. Defaults to empty string for the root node. + * @param isLast Whether this node is the last child of its parent. Affects branch character selection. + * @param isRoot Whether this is the root node of the tree. Determines initial branch style. + * @return A formatted Component representing this help node and all its children. + * @since 1.3.0 + */ + fun buildComponent(prefix: String = "", isLast: Boolean = true, isRoot: Boolean = true): Component { + val titleColor = Color(141, 184, 130) + val barColor = Color(100, 120, 90) // Dark green for tree bars + + // Tree structure branches + val branch = if (isRoot) "» " else if (isLast) "└ » " else "├ » " + + // Continuation for nested children + val childPrefix = if (isRoot) "" else prefix + if (isLast) " " else "│ " + + // Content prefix - vertical bar with separator/content + val contentPrefix = if (isRoot) "│ " else "$childPrefix│ " + val verticalBar = if (isRoot) "│" else "$childPrefix│" + + return component { + // Title + append(prefix) { color(barColor) } + append(branch) { color(barColor) } + append("$title\n") { color(titleColor) } + + // Separator line + append(contentPrefix + "─".repeat(10) + "\n") { color(barColor) } + + // Contents + val hasChildren = children.isNotEmpty() + val lastContentIndex = contents.size - 1 + + contents.forEachIndexed { idx, content -> + if (content.type == Content.Type.TITLE) { + append("$verticalBar\n") { color(barColor) } + } + + val isLastContent = idx == lastContentIndex && !hasChildren + val contentBranch = if (isLastContent) "└ " else "│ " + val contentLine = if (isRoot) contentBranch else childPrefix + contentBranch + + append(contentLine) { color(barColor) } + append("$content\n") { color(content.getColor()) } + } + + // Children with tree structure - parent manages ALL spacing + if (children.isNotEmpty()) { + // Blank line after content, before first child + append("$verticalBar\n") { color(barColor) } + + children.forEachIndexed { index, child -> + val childIsLast = index == children.size - 1 + append(child.buildComponent(childPrefix, childIsLast, isRoot = false)) + + // Add a blank line after each child (except the last) + if (!childIsLast) { + append("$verticalBar\n") { color(barColor) } + } + } + } + } + } + + /** + * Represents a single content item within a help node. + * + * Content items can be text descriptions, section titles, or list items. Each content item + * is rendered with appropriate formatting and color based on its type. + * + * @param text The text content to display. + * @param type The type of content, determining how it's formatted and colored. + * @since 1.3.0 + */ + class Content(val text: String, val type: Type) { + /** + * Returns a string representation of this content item. + * + * Formatting depends on the content type: + * - TEXT and TITLE are returned as-is + * - LIST items are prefixed with a bullet point (•) + * + * @return The formatted string representation of this content. + * @since 1.3.0 + */ + override fun toString(): String { + return when (type) { + Type.TEXT, Type.TITLE -> text + Type.LIST -> " • $text" + } + } + + /** + * Gets the display color for this content item based on its type. + * + * @return A Color object representing the appropriate color for this content type. + * @since 1.3.0 + */ + fun getColor(): Color { + return when (type) { + Type.TEXT, Type.LIST -> Color(169, 173, 168) + Type.TITLE -> Color(141, 184, 130) + } + } + + /** + * Enumeration of possible content types. + * + * @since 1.3.0 + */ + enum class Type { + /** Regular text content such as descriptions. */ + TEXT, + /** List item content, typically used for arguments or aliases. */ + LIST, + /** Section title content that groups related items. */ + TITLE + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/integr/backbone/systems/event/Event.kt b/src/main/kotlin/net/integr/backbone/systems/event/Event.kt index eb91aba..f0cee46 100644 --- a/src/main/kotlin/net/integr/backbone/systems/event/Event.kt +++ b/src/main/kotlin/net/integr/backbone/systems/event/Event.kt @@ -18,42 +18,71 @@ package net.integr.backbone.systems.event * * @since 1.0.0 */ -open class Event { - private var callback: Any? = null - private var isCancelled: Boolean = false - +abstract class Event { /** - * Sets the callback for the event. + * The callback for this event. + * + * Internally uses the [GLOBAL_CALLBACK] key on the callback-map. * - * @param value The callback object. * @since 1.0.0 */ - fun setCallback(value: Any?) { - callback = value - } + var callback: Any? + get() = callbacks[GLOBAL_CALLBACK] + set(value) { + callbacks[GLOBAL_CALLBACK] = value + } /** - * Cancels the event and stops it from spreading in the bus. - * - * @since 1.0.0 + * The callbacks for this event. + * @since 1.3.0 */ - fun cancel() { - isCancelled = true - } + private var callbacks: MutableMap = mutableMapOf() /** - * Get the callback object for the event. + * Adds a callback to this event. * - * @return The callback object, or null if not set. - * @since 1.0.0 + * @param key The key to store the callback under. + * @param value The callback to store. + */ + operator fun set(key: String, value: Any?) { + callbacks[key] = value + } + + /** + * Gets a callback from this event. + * @since 1.3.0 */ - fun callback(): Any? = callback + operator fun get(key: String): Any? { + return callbacks[key] + } + + companion object { + /** + * The global callback key. + * + * This is the default callback key used by Backbone. + * It is used to store the callback for the event. + * + * @since 1.3.0 + */ + const val GLOBAL_CALLBACK = "global" + } /** - * Checks if the event has been cancelled. + * Represents a cancelable Backbone event. Extend this class to create an event. * - * @return True if the event has been cancelled, false otherwise. - * @since 1.0.0 + * @since 1.3.0 */ - fun isCancelled() = isCancelled + abstract class Cancelable : Event() { + var canceled: Boolean = false + + /** + * Cancels the event and stops it from spreading in the bus. + * + * @since 1.3.0 + */ + fun cancel() { + canceled = true + } + } } \ No newline at end of file diff --git a/src/main/kotlin/net/integr/backbone/systems/event/EventBus.kt b/src/main/kotlin/net/integr/backbone/systems/event/EventBus.kt index ed27ff2..6eb6de7 100644 --- a/src/main/kotlin/net/integr/backbone/systems/event/EventBus.kt +++ b/src/main/kotlin/net/integr/backbone/systems/event/EventBus.kt @@ -24,7 +24,6 @@ import java.util.concurrent.ConcurrentSkipListSet import kotlin.reflect.KCallable import kotlin.reflect.KClass import kotlin.reflect.KType -import kotlin.reflect.full.defaultType import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.hasAnnotation import kotlin.reflect.full.starProjectedType @@ -238,8 +237,12 @@ object EventBus { /** * Posts an event to the bus, notifying all registered handlers. * + * Returns the global callback object from the event, if any. + * If you need to get a non-global callback, please use map + * access on the event itself. + * * @param event The event to post. - * @return The callback object from the event, or null if no callback was set. + * @return The global callback object from the event, or null if no global callback was set. * @since 1.0.0 */ fun post(event: Event): Any? { @@ -248,10 +251,10 @@ object EventBus { for (handler in eventHandlers) { callHandler(event, handler) - if (event.isCancelled()) return event.callback() + if (event is Event.Cancelable && event.canceled) return event.callback } - return event.callback() + return event.callback } /** diff --git a/src/main/kotlin/net/integr/backbone/systems/text/Alphabet.kt b/src/main/kotlin/net/integr/backbone/systems/text/Alphabet.kt index 8bedfce..324108c 100644 --- a/src/main/kotlin/net/integr/backbone/systems/text/Alphabet.kt +++ b/src/main/kotlin/net/integr/backbone/systems/text/Alphabet.kt @@ -13,11 +13,18 @@ package net.integr.backbone.systems.text + /** * A simple interface for custom alphabets. * @since 1.0.0 */ interface Alphabet { + /** + * The alphabet used for encoding. + * @since 1.3.0 + */ + val alphabet: String + /** * Encodes a string using the alphabet. * @@ -25,7 +32,20 @@ interface Alphabet { * @return The encoded string. * @since 1.0.0 */ - fun encode(str: String): String + fun encode(str: String): String { + val sb = StringBuilder() + + for (char in str) { + val index = DEFAULT_ALPHABET.indexOf(char) + if (index != -1) { + sb.append(alphabet[index]) + } else { + sb.append(char) + } + } + + return sb.toString() + } companion object { /** diff --git a/src/main/kotlin/net/integr/backbone/systems/text/Component.kt b/src/main/kotlin/net/integr/backbone/systems/text/Component.kt index aa2a613..3eb9f4e 100644 --- a/src/main/kotlin/net/integr/backbone/systems/text/Component.kt +++ b/src/main/kotlin/net/integr/backbone/systems/text/Component.kt @@ -58,7 +58,7 @@ fun component(block: ComponentBuilder.() -> Unit): Component { * @since 1.0.0 */ class ComponentBuilder { - private val base = Component.text() + val base = Component.text() /** * Appends a text component with optional styling. @@ -73,6 +73,18 @@ class ComponentBuilder { base.append(Component.text(text, style.build())) } + fun newLine() { + base.append(Component.newline()) + } + + fun space() { + base.append(Component.space()) + } + + fun append(component: Component) { + base.append(component) + } + /** * Builds the final [Component] from the appended parts. * @@ -90,7 +102,7 @@ class ComponentBuilder { * @since 1.0.0 */ class ComponentStyleBuilder { - private val base = Style.style() + val base = Style.style() /** * Sets the color of the component. diff --git a/src/main/kotlin/net/integr/backbone/text/alphabets/BoldSmallAlphabet.kt b/src/main/kotlin/net/integr/backbone/text/alphabets/BoldSmallAlphabet.kt index 8667494..9ea193e 100644 --- a/src/main/kotlin/net/integr/backbone/text/alphabets/BoldSmallAlphabet.kt +++ b/src/main/kotlin/net/integr/backbone/text/alphabets/BoldSmallAlphabet.kt @@ -14,31 +14,12 @@ package net.integr.backbone.text.alphabets import net.integr.backbone.systems.text.Alphabet -import kotlin.text.iterator /** * A custom alphabet that encodes strings into a bold small-caps style. + * **Important:** This alphabet will not handle 'Q' and 'q' correctly since there is no char for it. * @since 1.0.0 */ object BoldSmallAlphabet : Alphabet { - /** - * The bold small-caps alphabet characters. - * @since 1.0.0 - */ - const val ALPHABET = "ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘQʀꜱᴛᴜᴠᴡxʏᴢᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘQʀꜱᴛᴜᴠᴡxʏᴢ" - - override fun encode(str: String): String { - val sb = StringBuilder() - - for (char in str) { - val index = Alphabet.Companion.DEFAULT_ALPHABET.indexOf(char) - if (index != -1) { - sb.append(ALPHABET[index]) - } else { - sb.append(char) - } - } - - return sb.toString() - } + override val alphabet = "ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘQʀꜱᴛᴜᴠᴡxʏᴢᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘQʀꜱᴛᴜᴠᴡxʏᴢ" } \ No newline at end of file diff --git a/src/main/kotlin/net/integr/backbone/text/alphabets/TinyAlphabet.kt b/src/main/kotlin/net/integr/backbone/text/alphabets/TinyAlphabet.kt new file mode 100644 index 0000000..542471b --- /dev/null +++ b/src/main/kotlin/net/integr/backbone/text/alphabets/TinyAlphabet.kt @@ -0,0 +1,25 @@ +/* + * Copyright © 2026 Integr + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.integr.backbone.text.alphabets + +import net.integr.backbone.systems.text.Alphabet + +/** + * A custom alphabet that encodes strings into a tiny letter style. + * **Important:** This alphabet will not handle 'Q' and 'q' correctly since there is no char for it. + * @since 1.3.0 + */ +object TinyAlphabet : Alphabet { + override val alphabet = "ᴬᴮᶜᴰᴱᶠᴳᴴᴵᴶᴷᴸᴹᴺᴼᴾQᴿˢᵀᵁⱽᵂˣʸᶻᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖQʳˢᵗᵘᵛʷˣʸᶻ" +} \ No newline at end of file diff --git a/src/test/kotlin/net/integr/backbone/systems/event/EventBusBenchmark.kt b/src/test/kotlin/net/integr/backbone/systems/event/EventBusBenchmark.kt index 12d2076..b7440d1 100644 --- a/src/test/kotlin/net/integr/backbone/systems/event/EventBusBenchmark.kt +++ b/src/test/kotlin/net/integr/backbone/systems/event/EventBusBenchmark.kt @@ -17,7 +17,6 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import kotlin.system.measureNanoTime -import kotlin.system.measureTimeMillis /** * Comprehensive benchmark suite for the EventBus system. @@ -130,17 +129,17 @@ class EventBusBenchmark { EventBus.register(listener) val iterations = 100_000 - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { repeat(iterations) { EventBus.post(SimpleEvent()) } } println("Single handler, single event:") - println(" Total time: ${timeMs}ms") + println(" Total time: ${timeNs}ns") println(" Iterations: $iterations") - println(" Avg per event: ${timeMs.toDouble() / iterations}ms") - println(" Events/sec: ${(iterations / (timeMs.toDouble() / 1000)).toLong()}") + println(" Avg per event: ${timeNs.toDouble() / iterations}ns") + println(" Events/sec: ${(iterations / (timeNs.toDouble() / 1_000_000_000)).toLong()}") println(" Calls: ${listener.callCount}") } @@ -150,17 +149,17 @@ class EventBusBenchmark { listeners.forEach { EventBus.register(it) } val iterations = 50_000 - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { repeat(iterations) { EventBus.post(SimpleEvent()) } } println("10 handlers, single event:") - println(" Total time: ${timeMs}ms") + println(" Total time: ${timeNs}ns") println(" Iterations: $iterations") - println(" Avg per event: ${timeMs.toDouble() / iterations}ms") - println(" Events/sec: ${(iterations / (timeMs.toDouble() / 1000)).toLong()}") + println(" Avg per event: ${timeNs.toDouble() / iterations}ns") + println(" Events/sec: ${(iterations / (timeNs.toDouble() / 1_000_000_000)).toLong()}") println(" Total handler invocations: ${listeners.sumOf { it.callCount }}") } @@ -178,16 +177,16 @@ class EventBusBenchmark { val listeners = List(count) { SimpleListener() } listeners.forEach { EventBus.register(it) } - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { repeat(iterations) { EventBus.post(SimpleEvent()) } } - val avgPerEvent = timeMs.toDouble() / iterations - val eventsPerSec = (iterations / (timeMs.toDouble() / 1000)).toLong() + val avgPerEvent = timeNs.toDouble() / iterations + val eventsPerSec = (iterations / (timeNs.toDouble() / 1_000_000_000)).toLong() - println(" $count handlers: ${timeMs}ms total, ${String.format("%.4f", avgPerEvent)}ms/event, $eventsPerSec events/sec") + println(" $count handlers: ${timeNs}ns total, ${String.format("%.2f", avgPerEvent)}ns/event, $eventsPerSec events/sec") } } @@ -197,7 +196,7 @@ class EventBusBenchmark { EventBus.register(listener) val iterations = 30_000 - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { repeat(iterations) { EventBus.post(SimpleEvent()) EventBus.post(MediumEvent()) @@ -206,10 +205,10 @@ class EventBusBenchmark { } println("Multiple event types (3 types):") - println(" Total time: ${timeMs}ms") + println(" Total time: ${timeNs}ns") println(" Iterations: ${iterations * 3} (${iterations} of each type)") - println(" Avg per event: ${timeMs.toDouble() / (iterations * 3)}ms") - println(" Events/sec: ${((iterations * 3) / (timeMs.toDouble() / 1000)).toLong()}") + println(" Avg per event: ${timeNs.toDouble() / (iterations * 3)}ns") + println(" Events/sec: ${((iterations * 3) / (timeNs.toDouble() / 1_000_000_000)).toLong()}") println(" Simple: ${listener.simpleCount}, Medium: ${listener.mediumCount}, Complex: ${listener.complexCount}") } @@ -222,14 +221,14 @@ class EventBusBenchmark { val count = 1_000 val listeners = List(count) { SimpleListener() } - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { listeners.forEach { EventBus.register(it) } } println("Registration performance:") println(" Registered: $count listeners") - println(" Total time: ${timeMs}ms") - println(" Avg per registration: ${timeMs.toDouble() / count}ms") + println(" Total time: ${timeNs}ns") + println(" Avg per registration: ${timeNs.toDouble() / count}ns") } @Test @@ -238,21 +237,21 @@ class EventBusBenchmark { val listeners = List(count) { SimpleListener() } listeners.forEach { EventBus.register(it) } - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { listeners.forEach { EventBus.unregister(it) } } println("Unregistration performance:") println(" Unregistered: $count listeners") - println(" Total time: ${timeMs}ms") - println(" Avg per unregistration: ${timeMs.toDouble() / count}ms") + println(" Total time: ${timeNs}ns") + println(" Avg per unregistration: ${timeNs.toDouble() / count}ns") } @Test fun benchmarkChurnRegistrationUnregistration() { val iterations = 100 - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { repeat(iterations) { val listener = SimpleListener() EventBus.register(listener) @@ -263,8 +262,8 @@ class EventBusBenchmark { println("Churn test (register -> post -> unregister):") println(" Iterations: $iterations") - println(" Total time: ${timeMs}ms") - println(" Avg per cycle: ${timeMs.toDouble() / iterations}ms") + println(" Total time: ${timeNs}ns") + println(" Avg per cycle: ${timeNs.toDouble() / iterations}ns") } // ============================================ @@ -277,7 +276,7 @@ class EventBusBenchmark { listeners.forEach { EventBus.register(it) } val iterations = 10_000 - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { repeat(iterations) { EventBus.post(SimpleEvent()) EventBus.post(MediumEvent()) @@ -288,8 +287,8 @@ class EventBusBenchmark { println("Priority sorting overhead:") println(" Listeners: ${listeners.size} (each with 3 different priority handlers)") println(" Total events: ${iterations * 3}") - println(" Total time: ${timeMs}ms") - println(" Avg per event: ${timeMs.toDouble() / (iterations * 3)}ms") + println(" Total time: ${timeNs}ns") + println(" Avg per event: ${timeNs.toDouble() / (iterations * 3)}ns") } // ============================================ @@ -316,7 +315,7 @@ class EventBusBenchmark { Thread.sleep(100) val iterations = 10_000 - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { repeat(iterations) { EventBus.post(SimpleEvent()) } @@ -326,8 +325,8 @@ class EventBusBenchmark { println(" Live handlers: ${liveListeners.size}") println(" Dead handlers: ~10 (GC-dependent)") println(" Iterations: $iterations") - println(" Total time: ${timeMs}ms") - println(" Avg per event: ${timeMs.toDouble() / iterations}ms") + println(" Total time: ${timeNs}ns") + println(" Avg per event: ${timeNs.toDouble() / iterations}ns") println(" Total live calls: ${liveListeners.sumOf { it.callCount }}") } @@ -362,9 +361,9 @@ class EventBusBenchmark { val avgSubsequent = subsequentTimes.average() println("Dead handler cleanup impact:") - println(" First post (with cleanup): ${firstPostTime / 1_000}µs") - println(" Avg subsequent posts: ${String.format("%.2f", avgSubsequent / 1_000)}µs") - println(" Cleanup overhead: ${String.format("%.2f", (firstPostTime - avgSubsequent) / 1_000)}µs") + println(" First post (with cleanup): ${firstPostTime}ns") + println(" Avg subsequent posts: ${String.format("%.2f", avgSubsequent)}ns") + println(" Cleanup overhead: ${String.format("%.2f", (firstPostTime - avgSubsequent))}ns") } // ============================================ @@ -383,7 +382,7 @@ class EventBusBenchmark { heavyListeners.forEach { EventBus.register(it) } val iterations = 5_000 - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { repeat(iterations) { EventBus.post(SimpleEvent()) if (it % 2 == 0) EventBus.post(MediumEvent()) @@ -398,9 +397,9 @@ class EventBusBenchmark { println(" Multi listeners: ${multiListeners.size}") println(" Heavy listeners: ${heavyListeners.size}") println(" Total events posted: $totalEvents") - println(" Total time: ${timeMs}ms") - println(" Avg per event: ${timeMs.toDouble() / totalEvents}ms") - println(" Events/sec: ${(totalEvents / (timeMs.toDouble() / 1000)).toLong()}") + println(" Total time: ${timeNs}ns") + println(" Avg per event: ${timeNs.toDouble() / totalEvents}ns") + println(" Events/sec: ${(totalEvents / (timeNs.toDouble() / 1_000_000_000)).toLong()}") } @Test @@ -418,51 +417,21 @@ class EventBusBenchmark { println() repeat(bursts) { burstNum -> - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { repeat(eventsPerBurst) { EventBus.post(SimpleEvent()) } } - results.add(timeMs) - println(" Burst ${burstNum + 1}: ${timeMs}ms, ${(eventsPerBurst / (timeMs.toDouble() / 1000)).toLong()} events/sec") + results.add(timeNs) + println(" Burst ${burstNum + 1}: ${timeNs}ns, ${(eventsPerBurst / (timeNs.toDouble() / 1_000_000_000)).toLong()} events/sec") } println() - println(" Avg burst time: ${results.average()}ms") - println(" Min: ${results.minOrNull()}ms, Max: ${results.maxOrNull()}ms") + println(" Avg burst time: ${results.average()}ns") + println(" Min: ${results.minOrNull()}ns, Max: ${results.maxOrNull()}ns") println(" Total calls: ${listener.callCount}") } - // ============================================ - // MEMORY AND OVERHEAD BENCHMARKS - // ============================================ - - @Test - fun benchmarkMemoryOverhead() { - val runtime = Runtime.getRuntime() - - // Baseline memory - System.gc() - Thread.sleep(100) - val baselineMemory = runtime.totalMemory() - runtime.freeMemory() - - // Register many handlers - val listeners = List(1000) { MultiHandlerListener() } - listeners.forEach { EventBus.register(it) } - - System.gc() - Thread.sleep(100) - val afterRegistrationMemory = runtime.totalMemory() - runtime.freeMemory() - - val memoryUsed = (afterRegistrationMemory - baselineMemory) / 1024 / 1024 - - println("Memory overhead estimation:") - println(" Baseline memory: ${baselineMemory / 1024 / 1024}MB") - println(" After registering 1000 listeners: ${afterRegistrationMemory / 1024 / 1024}MB") - println(" Memory used by handlers: ~${memoryUsed}MB") - println(" Avg per listener: ~${memoryUsed.toDouble() / 1000}MB") - } - @Test fun benchmarkConcurrentPosting() { val listener = SimpleListener() @@ -471,7 +440,7 @@ class EventBusBenchmark { val threads = 4 val iterationsPerThread = 25_000 - val timeMs = measureTimeMillis { + val timeNs = measureNanoTime { val threadList = List(threads) { Thread { repeat(iterationsPerThread) { @@ -490,8 +459,8 @@ class EventBusBenchmark { println(" Threads: $threads") println(" Events per thread: $iterationsPerThread") println(" Total events: $totalEvents") - println(" Total time: ${timeMs}ms") - println(" Events/sec: ${(totalEvents / (timeMs.toDouble() / 1000)).toLong()}") + println(" Total time: ${timeNs}ns") + println(" Events/sec: ${(totalEvents / (timeNs.toDouble() / 1_000_000_000)).toLong()}") println(" Handler calls: ${listener.callCount}") } } diff --git a/src/test/kotlin/net/integr/backbone/systems/event/EventBusTest.kt b/src/test/kotlin/net/integr/backbone/systems/event/EventBusTest.kt index 054021d..9467ab7 100644 --- a/src/test/kotlin/net/integr/backbone/systems/event/EventBusTest.kt +++ b/src/test/kotlin/net/integr/backbone/systems/event/EventBusTest.kt @@ -11,6 +11,8 @@ * limitations under the License. */ +@file:Suppress("unused", "AssignedValueIsNeverRead") + package net.integr.backbone.systems.event import org.junit.jupiter.api.AfterEach @@ -19,6 +21,7 @@ import org.junit.jupiter.api.Test class EventBusTest { class TestEvent : Event() + class CancelableTestEvent : Event.Cancelable() @Test fun testRegisterAndPost() { @@ -88,7 +91,7 @@ class EventBusTest { class TestListener { @BackboneEventHandler fun onEvent(event: TestEvent) { - event.setCallback("Callback from handler") + event.callback = "Callback from handler" } } @@ -98,7 +101,7 @@ class EventBusTest { val result = EventBus.post(event) assertEquals("Callback from handler", result) - assertEquals("Callback from handler", event.callback()) + assertEquals("Callback from handler", event.callback) } @Test @@ -184,11 +187,11 @@ class EventBusTest { } } - // Create listener and register it + // Create a listener and register it var listener: TestListener? = TestListener() EventBus.register(listener!!) - // First post should work + // The first post should work EventBus.post(TestEvent()) assertEquals(1, callCount, "Handler should have been called") @@ -218,7 +221,7 @@ class EventBusTest { } } - // Create listener, register it, then clear reference + // Create a listener, register it, then clear a reference var listener: TestListener? = TestListener() EventBus.register(listener!!) listener = null @@ -229,10 +232,10 @@ class EventBusTest { System.gc() Thread.sleep(100) - // Post event - should detect dead handler + // Post-event - should detect a dead handler EventBus.post(TestEvent()) - // Post again - dead handler should have been removed from first post + // Post again - dead handler should have been removed from the first post EventBus.post(TestEvent()) // No exceptions should be thrown @@ -254,11 +257,11 @@ class EventBusTest { val listener = TestListener() EventBus.register(listener) - // First post should throw and increment count + // The first post should throw and increment count EventBus.post(TestEvent()) assertEquals(1, callCount, "Handler should have been called once") - // Second post should not call handler (it was removed due to exception) + // The second post should not call handler (it was removed due to an exception) EventBus.post(TestEvent()) assertEquals(1, callCount, "Handler should have been removed after exception") } @@ -282,7 +285,7 @@ class EventBusTest { } } - // Register alive listener (keep strong reference) + // Register a live listener (keep strong reference) val aliveListener = AliveListener() EventBus.register(aliveListener) @@ -300,10 +303,10 @@ class EventBusTest { // Post event EventBus.post(TestEvent()) - // Alive handler should be called + // Live handler should be called assertEquals(1, aliveCallCount, "Alive handler should have been called") - // Post again to ensure system still works after cleanup + // Post again to ensure a system still works after cleanup EventBus.post(TestEvent()) assertEquals(2, aliveCallCount, "Alive handler should have been called again") } @@ -319,7 +322,7 @@ class EventBusTest { } } - // Keep strong reference to listener + // Keep strong reference to the listener val listener = TestListener() EventBus.register(listener) @@ -384,11 +387,11 @@ class EventBusTest { } } - // Register first instance and keep it alive + // Register the first instance and keep it alive val listener1 = TestListener().apply { id = 1 } EventBus.register(listener1) - // Register second instance and let it die + // Register the second instance and let it die var listener2: TestListener? = TestListener().apply { id = 2 } EventBus.register(listener2!!) listener2 = null @@ -402,14 +405,183 @@ class EventBusTest { // Post event EventBus.post(TestEvent()) - // First instance should be called + // The first instance should be called assertEquals(1, instance1Calls, "Alive instance should be called") - // Post again to verify first instance still works + // Post again to verify the first instance still works EventBus.post(TestEvent()) assertEquals(2, instance1Calls, "Alive instance should continue working") } + @Test + fun testCancelableEventBasicCancellation() { + class TestListener { + @BackboneEventHandler + fun onEvent(event: CancelableTestEvent) { + event.cancel() + } + } + + val listener = TestListener() + EventBus.register(listener) + val event = CancelableTestEvent() + EventBus.post(event) + + assertTrue(event.canceled, "Event should be canceled") + } + + @Test + fun testCancelableEventStopsPropagation() { + val callOrder = mutableListOf() + + class TestListener { + @BackboneEventHandler(priority = EventPriority.THREE_BEFORE) + fun onFirst(event: CancelableTestEvent) { + callOrder.add(1) + event.cancel() + } + + @BackboneEventHandler(priority = EventPriority.NORMAL) + fun onSecond(event: CancelableTestEvent) { + callOrder.add(2) + } + + @BackboneEventHandler(priority = EventPriority.THREE_AFTER) + fun onThird(event: CancelableTestEvent) { + callOrder.add(3) + } + } + + val listener = TestListener() + EventBus.register(listener) + EventBus.post(CancelableTestEvent()) + + assertEquals(listOf(1), callOrder, "Only the first handler should be called before cancellation") + } + + @Test + fun testCancelableEventWithPriorities() { + var highPriorityCalled = false + var lowPriorityCalled = false + + class HighPriorityListener { + @BackboneEventHandler(priority = EventPriority.THREE_BEFORE) + fun onEvent(event: CancelableTestEvent) { + highPriorityCalled = true + event.cancel() + } + } + + class LowPriorityListener { + @BackboneEventHandler(priority = EventPriority.THREE_AFTER) + fun onEvent(event: CancelableTestEvent) { + lowPriorityCalled = true + } + } + + val highListener = HighPriorityListener() + val lowListener = LowPriorityListener() + EventBus.register(highListener) + EventBus.register(lowListener) + EventBus.post(CancelableTestEvent()) + + assertTrue(highPriorityCalled, "High priority handler should be called") + assertFalse(lowPriorityCalled, "Low priority handler should not be called after cancellation") + } + + @Test + fun testCancelableEventWithCallback() { + class TestListener { + @BackboneEventHandler + fun onEvent(event: CancelableTestEvent) { + event.callback = "Callback value" + event.cancel() + } + } + + val listener = TestListener() + EventBus.register(listener) + val event = CancelableTestEvent() + val result = EventBus.post(event) + + assertEquals("Callback value", result, "Callback should be returned when event is canceled") + assertEquals("Callback value", event.callback, "Event callback should be set") + assertTrue(event.canceled, "Event should be canceled") + } + + @Test + fun testCancelableEventNotCanceled() { + val callOrder = mutableListOf() + + class TestListener { + @BackboneEventHandler(priority = EventPriority.THREE_BEFORE) + fun onFirst(event: CancelableTestEvent) { + callOrder.add(1) + } + + @BackboneEventHandler(priority = EventPriority.NORMAL) + fun onSecond(event: CancelableTestEvent) { + callOrder.add(2) + } + + @BackboneEventHandler(priority = EventPriority.THREE_AFTER) + fun onThird(event: CancelableTestEvent) { + callOrder.add(3) + } + } + + val listener = TestListener() + EventBus.register(listener) + val event = CancelableTestEvent() + EventBus.post(event) + + assertEquals(listOf(1, 2, 3), callOrder, "All handlers should be called when event is not canceled") + assertFalse(event.canceled, "Event should not be canceled") + } + + @Test + fun testMultipleListenersWithMixedCancellation() { + var listener1Called = false + var listener2Called = false + var listener3Called = false + + class CancelingListener { + @BackboneEventHandler(priority = EventPriority.NORMAL) + fun onEvent(event: CancelableTestEvent) { + listener1Called = true + event.cancel() + } + } + + class NonCancelingListener1 { + @BackboneEventHandler(priority = EventPriority.THREE_BEFORE) + fun onEvent(event: CancelableTestEvent) { + listener2Called = true + } + } + + class NonCancelingListener2 { + @BackboneEventHandler(priority = EventPriority.THREE_AFTER) + fun onEvent(event: CancelableTestEvent) { + listener3Called = true + } + } + + val cancelingListener = CancelingListener() + val nonCancelingListener1 = NonCancelingListener1() + val nonCancelingListener2 = NonCancelingListener2() + + EventBus.register(cancelingListener) + EventBus.register(nonCancelingListener1) + EventBus.register(nonCancelingListener2) + + EventBus.post(CancelableTestEvent()) + + assertTrue(listener2Called, "Higher priority non-canceling listener should be called") + assertTrue(listener1Called, "Canceling listener should be called") + assertFalse(listener3Called, "Lower priority listener should not be called after cancellation") + } + @AfterEach fun tearDown() { EventBus.clear() From ae96d33fc2386311ea2e07a151d8983e672e169d Mon Sep 17 00:00:00 2001 From: integr-dev Date: Sun, 8 Mar 2026 14:18:07 +0100 Subject: [PATCH 3/3] Bump version to 1.4.0. Add dialog system with DSL support and custom interaction handling for entities. --- build.gradle.kts | 2 +- .../kotlin/net/integr/backbone/Backbone.kt | 1 - src/main/kotlin/net/integr/backbone/Utils.kt | 40 +++++++ .../integr/backbone/systems/dialog/Dialog.kt | 105 ++++++++++++++++++ .../backbone/systems/entity/CustomEntity.kt | 10 ++ .../backbone/systems/entity/EntityHandler.kt | 22 ++++ .../kotlin/net/integr/backbone/UtilsTest.kt | 20 ++++ 7 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/net/integr/backbone/systems/dialog/Dialog.kt diff --git a/build.gradle.kts b/build.gradle.kts index 3bbe839..590645b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "net.integr" -version = "1.3.0" +version = "1.4.0" repositories { mavenCentral() diff --git a/src/main/kotlin/net/integr/backbone/Backbone.kt b/src/main/kotlin/net/integr/backbone/Backbone.kt index 2f01f02..5cf3dc0 100644 --- a/src/main/kotlin/net/integr/backbone/Backbone.kt +++ b/src/main/kotlin/net/integr/backbone/Backbone.kt @@ -36,7 +36,6 @@ import org.jetbrains.annotations.ApiStatus * @since 1.0.0 */ object Backbone { - //TODO: dialogues /** * Backbones internal storage pool. **Important:** Do not use this. * Create a new pool instead: diff --git a/src/main/kotlin/net/integr/backbone/Utils.kt b/src/main/kotlin/net/integr/backbone/Utils.kt index 22e5f36..ad40503 100644 --- a/src/main/kotlin/net/integr/backbone/Utils.kt +++ b/src/main/kotlin/net/integr/backbone/Utils.kt @@ -13,6 +13,9 @@ package net.integr.backbone +import net.kyori.adventure.builder.AbstractBuilder +import kotlin.reflect.full.declaredMemberFunctions + /** * Utility functions for various tasks. * @since 1.0.0 @@ -54,4 +57,41 @@ object Utils { fun isUid(string: String): Boolean { return string.matches("^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$".toRegex()) } + + /** + * Used to more easily get the result of a builder with applied block. + * + * Example: + * ```kotlin + * val builder = Something.builder() + * builder.block() + * val result = builder.build() + * ``` + * + * is changed to + * + * ```kotlin + * val result = blockBuild(Something.builder(), block) + * ``` + * + * Invokes a builders build method via reflection. + * Does not run any safety checks. It is your job to figure out + * if this will work or not. + * + * @param T the builder class + * @param U the builders result class + * @param builder the builder instance + * @param block the block to apply to the builder + * @since 1.4.0 + */ + inline fun blockBuild(builder: T, block: T.() -> Unit): U { + builder.block() + // Assume a build method is there + val method = builder::class.java.getDeclaredMethod("build") + + // It is the users duty to only call this on builders with this signature + @Suppress("UNCHECKED_CAST") + val result = method.invoke(builder) as U + return result + } } \ No newline at end of file diff --git a/src/main/kotlin/net/integr/backbone/systems/dialog/Dialog.kt b/src/main/kotlin/net/integr/backbone/systems/dialog/Dialog.kt new file mode 100644 index 0000000..9d49813 --- /dev/null +++ b/src/main/kotlin/net/integr/backbone/systems/dialog/Dialog.kt @@ -0,0 +1,105 @@ +/* + * Copyright © 2026 Integr + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +@file:Suppress("UnstableApiUsage") + +package net.integr.backbone.systems.dialog + +import io.papermc.paper.dialog.Dialog +import io.papermc.paper.dialog.DialogResponseView +import io.papermc.paper.registry.RegistryBuilderFactory +import io.papermc.paper.registry.data.dialog.DialogBase +import io.papermc.paper.registry.data.dialog.DialogRegistryEntry +import io.papermc.paper.registry.data.dialog.action.DialogAction +import io.papermc.paper.registry.data.dialog.type.DialogType +import net.integr.backbone.Utils +import net.kyori.adventure.audience.Audience +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickCallback +import java.util.function.Consumer + + +/** + * Builds a Paper [Dialog] using a DSL. + * + * Example usage: + * ```kotlin + * dialog { + * base(component { append("My Dialog") }) { + * // configure base properties + * } + * type(myDialogType) + * } + * ``` + * + * @param block The DSL block to construct the dialog. + * @return The built [Dialog]. + * @since 1.4.0 + */ +fun dialog(block: DialogBuilder.() -> Unit): Dialog { + val con = Consumer { builder: RegistryBuilderFactory -> + val db = DialogBuilder(builder.empty()) + db.block() + } + + return Dialog.create(con) +} + +/** + * A DSL builder for creating a Paper [Dialog]. + * + * @param builder The underlying [DialogRegistryEntry.Builder] used to construct the dialog. + * @since 1.4.0 + */ +class DialogBuilder(val builder: DialogRegistryEntry.Builder) { + /** + * Sets the base configuration of the dialog, including its title and additional properties. + * + * @param title The title [Component] of the dialog. + * @param block A DSL block to configure the [DialogBase.Builder]. + * @since 1.4.0 + */ + fun base(title: Component, block: DialogBase.Builder.() -> Unit) { + val res = Utils.blockBuild(DialogBase.builder(title), block) + builder.base(res) + } + + /** + * Sets the type of the dialog. + * + * @param type The [DialogType] to use for this dialog. + * @since 1.4.0 + */ + fun type(type: DialogType) { + builder.type(type) + } +} + +/** + * Creates a custom click [DialogAction] with the given options and callback. + * + * The callback is invoked when the user interacts with the dialog action, + * providing the [DialogResponseView] and the [Audience] that triggered the action. + * + * @param options The [ClickCallback.Options] to configure the click behavior. + * @param callback The callback to execute when the action is triggered. + * @return A [DialogAction] representing the custom click action. + * @since 1.4.0 + */ +fun customClick( + options: ClickCallback.Options, + callback: (response: DialogResponseView, audience: Audience) -> Unit +): DialogAction { + return DialogAction.customClick(callback, options) +} \ No newline at end of file diff --git a/src/main/kotlin/net/integr/backbone/systems/entity/CustomEntity.kt b/src/main/kotlin/net/integr/backbone/systems/entity/CustomEntity.kt index 646793f..ec8b856 100644 --- a/src/main/kotlin/net/integr/backbone/systems/entity/CustomEntity.kt +++ b/src/main/kotlin/net/integr/backbone/systems/entity/CustomEntity.kt @@ -22,6 +22,7 @@ import org.bukkit.Location import org.bukkit.World import org.bukkit.entity.EntityType import org.bukkit.entity.Mob +import org.bukkit.event.player.PlayerInteractEntityEvent import org.bukkit.persistence.PersistentDataType import org.jetbrains.annotations.ApiStatus @@ -70,6 +71,15 @@ abstract class CustomEntity(val id: String, val type: EntityType, val s setupGoals(mob as T) } + /** + * Called when a player interacts with the entity. Override this method to define custom interaction behavior. + * This method is called from the [EntityHandler] when a player interacts with an entity that has the custom entity ID tag. + * + * @param event The event that was fired. + * @since 1.4.0 + */ + open fun onInteract(event: PlayerInteractEntityEvent) {} + /** * Spawn the custom entity at the given location in the specified world. * diff --git a/src/main/kotlin/net/integr/backbone/systems/entity/EntityHandler.kt b/src/main/kotlin/net/integr/backbone/systems/entity/EntityHandler.kt index 8468f8d..665b5d8 100644 --- a/src/main/kotlin/net/integr/backbone/systems/entity/EntityHandler.kt +++ b/src/main/kotlin/net/integr/backbone/systems/entity/EntityHandler.kt @@ -22,6 +22,7 @@ import org.bukkit.entity.Entity import org.bukkit.entity.Mob import org.bukkit.event.EventHandler import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerInteractEntityEvent import org.bukkit.event.world.EntitiesLoadEvent import org.bukkit.persistence.PersistentDataType import org.jetbrains.annotations.ApiStatus @@ -90,4 +91,25 @@ object EntityHandler : Listener { } } } + + + /** + * Called by bukkit. + * @since 1.4.0 + */ + @ApiStatus.Internal + @EventHandler + fun onPlayerInteractEntity(event: PlayerInteractEntityEvent) { + val entity = event.rightClicked + val id = PersistenceHelper.read(entity, PersistenceKeys.BACKBONE_CUSTOM_ENTITY_UID.key, PersistentDataType.STRING) + if (id != null) { + val customEntity = entities[id] + if (customEntity == null) { + logger.warning("Custom entity not found for entity: ${entity.entityId} is '$id' at ${entity.location}") + return + } + + customEntity.onInteract(event) + } + } } \ No newline at end of file diff --git a/src/test/kotlin/net/integr/backbone/UtilsTest.kt b/src/test/kotlin/net/integr/backbone/UtilsTest.kt index ac8afef..148201b 100644 --- a/src/test/kotlin/net/integr/backbone/UtilsTest.kt +++ b/src/test/kotlin/net/integr/backbone/UtilsTest.kt @@ -41,4 +41,24 @@ class UtilsTest { assertFalse(Utils.isUid("not-a-uuid")) assertFalse(Utils.isUid("")) } + + private class TestBuilder { + private var value: String = "" + + fun setValue(v: String) { + value = v + } + + fun build(): String { + return "Built: $value" + } + } + + @Test + fun testBlockBuild() { + val result: String = Utils.blockBuild(TestBuilder()) { + setValue("Hello, World!") + } + assertEquals("Built: Hello, World!", result) + } } \ No newline at end of file