From f23bb2178f66f84ea0beb83ca90b40ac55660da5 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Tue, 28 Apr 2026 16:30:16 +0200 Subject: [PATCH] feat(binding): Hytale SDK glue The only module that imports com.hypixel.*. Provides World.asExecutor, EntityHandle adapters for Ref/PlayerRef/Player, scope helpers (playerScope, worldScope, pluginScope), and installAsync() which wires PlayerDisconnectEvent to PlayerScopes.cancel. --- binding/README.md | 48 +++++++++++++++++ binding/build.gradle.kts | 16 ++++++ .../async/binding/EntityHandleAdapter.kt | 51 +++++++++++++++++++ .../com/mythlane/async/binding/Extensions.kt | 33 ++++++++++++ .../async/binding/HytaleAsyncInstaller.kt | 25 +++++++++ .../mythlane/async/binding/WorldAdapter.kt | 25 +++++++++ 6 files changed, 198 insertions(+) create mode 100644 binding/README.md create mode 100644 binding/build.gradle.kts create mode 100644 binding/src/main/kotlin/com/mythlane/async/binding/EntityHandleAdapter.kt create mode 100644 binding/src/main/kotlin/com/mythlane/async/binding/Extensions.kt create mode 100644 binding/src/main/kotlin/com/mythlane/async/binding/HytaleAsyncInstaller.kt create mode 100644 binding/src/main/kotlin/com/mythlane/async/binding/WorldAdapter.kt diff --git a/binding/README.md b/binding/README.md new file mode 100644 index 0000000..c040e9f --- /dev/null +++ b/binding/README.md @@ -0,0 +1,48 @@ +# `:binding` + +The Hytale-specific glue. This is the only module that imports anything from +`com.hypixel.hytale.*` — everything else in the project is plain Kotlin you +can compile without the SDK on the classpath. + +## What's in here + +```kotlin +World.asExecutor(): WorldExecutor +Ref.toEntityHandle(world: World): EntityHandle +PlayerRef.toEntityHandle(): EntityHandle +Player.handle(): EntityHandle + +JavaPlugin.installAsync() // wires PlayerDisconnectEvent → PlayerScopes.cancel + +playerScope(player): CoroutineScope +worldScope(world): CoroutineScope +pluginScope(plugin): CoroutineScope +``` + +Each is a thin delegating wrapper. The interesting code lives in `:core` and +`:ecs`. + +## No unit tests + +`World` and `WorldConfig` break MockK's bytecode retransformer on JDK 25 +(`InternalError: class redefinition failed`). Adding `mockk-agent` plus a JVM +arg flag would let us mock them, but they'd be testing five lines of method +delegation. Not worth the maintenance. + +The actual behaviour — dispatcher confinement, scope cancellation, the DSL — +is covered by the `:core` and `:ecs` unit tests, which run in milliseconds +against in-memory stubs. + +If you want to verify the binding against a live server, build the shaded +JAR (`./gradlew :dist:shadowJar`), drop it in `mods/`, and call +`installAsync()` from a plugin's `start()`. If `playerScope(player).launch { modify(...) { ... } }` +runs without throwing `Assert not in thread!`, the binding works. + +## Known SDK gotchas + +- **No `WorldUnloadEvent`.** Until it ships, call `WorldScopes.cancel(uuid)` + yourself when you unload a world. +- **Race on world death.** If a world dies between an `isAlive()` check and + the actual `execute()` call, the task is silently dropped by Hytale's task + queue and the awaiting coroutine never completes. Wrap long world-bound + work in `withTimeout(...)` if it matters. diff --git a/binding/build.gradle.kts b/binding/build.gradle.kts new file mode 100644 index 0000000..c2fcbc9 --- /dev/null +++ b/binding/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + `java-library` +} + +dependencies { + api(project(":core")) + api(project(":ecs")) + compileOnly(libs.hytale.server) + + testImplementation(libs.hytale.server) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.kotest.assertions) + testImplementation(libs.mockk) +} diff --git a/binding/src/main/kotlin/com/mythlane/async/binding/EntityHandleAdapter.kt b/binding/src/main/kotlin/com/mythlane/async/binding/EntityHandleAdapter.kt new file mode 100644 index 0000000..9630e35 --- /dev/null +++ b/binding/src/main/kotlin/com/mythlane/async/binding/EntityHandleAdapter.kt @@ -0,0 +1,51 @@ +package com.mythlane.async.binding + +import com.hypixel.hytale.component.ComponentType +import com.hypixel.hytale.component.Ref +import com.hypixel.hytale.server.core.universe.PlayerRef +import com.hypixel.hytale.server.core.universe.Universe +import com.hypixel.hytale.server.core.universe.world.World +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore +import com.mythlane.async.dispatchers.WorldExecutor +import com.mythlane.async.ecs.EntityHandle + +/** + * Adapts a Hytale `Ref` to the [EntityHandle] abstraction. + * + * The [typeKey] passed to [get] is the opaque `Any` token registered via + * `ComponentRegistry.register(componentType)` — at runtime it must be a + * `ComponentType`. + * + * Resolving the owning [World] from a bare `Ref` is not possible + * without context (the runtime store does not expose a typed `getWorld()`). + * Use [PlayerRef.toEntityHandle] for the player case (we resolve the world via + * `playerRef.worldUuid` + `HytaleServer.get().getWorld(uuid)`); for arbitrary + * refs, pass the [World] explicitly via [toEntityHandle]. + */ +internal class HytaleEntityHandle( + private val ref: Ref, + override val world: WorldExecutor, +) : EntityHandle { + @Suppress("UNCHECKED_CAST") + override fun get(typeKey: Any): T? { + val componentType = typeKey as ComponentType + return ref.store.getComponent(ref, componentType as ComponentType) as T? + } +} + +/** Wraps this `Ref` as an [EntityHandle] using the given [world]. */ +fun Ref.toEntityHandle(world: World): EntityHandle = + HytaleEntityHandle(this, world.asExecutor()) + +/** + * Convenience: wraps this player's reference as an [EntityHandle], resolving + * the owning world via `playerRef.worldUuid`. + * + * @throws IllegalStateException if the player is not currently in a world + * (e.g. mid-handshake) or the world UUID can't be resolved server-side. + */ +fun PlayerRef.toEntityHandle(): EntityHandle { + val worldUuid = checkNotNull(worldUuid) { "PlayerRef has no worldUuid (player not in a world?)" } + val world = checkNotNull(Universe.get().getWorld(worldUuid)) { "World $worldUuid not found" } + return reference!!.toEntityHandle(world) +} diff --git a/binding/src/main/kotlin/com/mythlane/async/binding/Extensions.kt b/binding/src/main/kotlin/com/mythlane/async/binding/Extensions.kt new file mode 100644 index 0000000..cc001d0 --- /dev/null +++ b/binding/src/main/kotlin/com/mythlane/async/binding/Extensions.kt @@ -0,0 +1,33 @@ +package com.mythlane.async.binding + +import com.hypixel.hytale.server.core.entity.entities.Player +import com.hypixel.hytale.server.core.universe.world.World +import com.mythlane.async.ecs.EntityHandle +import com.mythlane.async.scope.PlayerScopes +import com.mythlane.async.scope.PluginScopes +import com.mythlane.async.scope.WorldScopes +import kotlinx.coroutines.CoroutineScope + +/** + * Get-or-create the [CoroutineScope] for [player]. + * + * **Readiness contract.** Call only after the player has reached `PlayerReadyEvent`. + * Earlier in the connection lifecycle, [Player.uuid] may still be null and this + * function will NPE. If you need to schedule work before ready, key off the + * raw UUID via [PlayerScopes.of] once you have one. + */ +fun playerScope(player: Player): CoroutineScope = PlayerScopes.of(player.uuid!!) + +/** Get-or-create the [CoroutineScope] for [world]. */ +fun worldScope(world: World): CoroutineScope = WorldScopes.of(world.worldConfig.uuid) + +/** Get-or-create the [CoroutineScope] for [plugin]. */ +fun pluginScope(plugin: Any): CoroutineScope = PluginScopes.of(plugin) + +/** + * Convenience: returns this player's [EntityHandle], suitable for the component DSL. + * + * Resolves the owning world via `playerRef.worldUuid` lookup. Same readiness + * contract as [playerScope] — call only after `PlayerReadyEvent`. + */ +fun Player.handle(): EntityHandle = playerRef!!.toEntityHandle() diff --git a/binding/src/main/kotlin/com/mythlane/async/binding/HytaleAsyncInstaller.kt b/binding/src/main/kotlin/com/mythlane/async/binding/HytaleAsyncInstaller.kt new file mode 100644 index 0000000..6ffc8d7 --- /dev/null +++ b/binding/src/main/kotlin/com/mythlane/async/binding/HytaleAsyncInstaller.kt @@ -0,0 +1,25 @@ +package com.mythlane.async.binding + +import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent +import com.hypixel.hytale.server.core.plugin.JavaPlugin +import com.mythlane.async.scope.PlayerScopes + +/** + * Wires Async's lifecycle hooks into a plugin. Call once from `start()`. + * + * What it installs (v0.1): + * - On [PlayerDisconnectEvent]: cancels the player's scope via [PlayerScopes.cancel]. + * + * What it does NOT install: + * - **World unload cancellation.** The Hytale API does not currently expose a + * public world-unload event. Cancel [com.mythlane.async.scope.WorldScopes] + * manually from your shutdown / world-management code until that ships. + * + * Pair with [com.mythlane.async.Async.shutdown] in your plugin's + * `shutdown()` to drain every remaining scope. + */ +fun JavaPlugin.installAsync() { + eventRegistry.register(PlayerDisconnectEvent::class.java) { event -> + PlayerScopes.cancel(event.playerRef.uuid) + } +} diff --git a/binding/src/main/kotlin/com/mythlane/async/binding/WorldAdapter.kt b/binding/src/main/kotlin/com/mythlane/async/binding/WorldAdapter.kt new file mode 100644 index 0000000..27eb7eb --- /dev/null +++ b/binding/src/main/kotlin/com/mythlane/async/binding/WorldAdapter.kt @@ -0,0 +1,25 @@ +package com.mythlane.async.binding + +import com.hypixel.hytale.server.core.universe.world.World +import com.mythlane.async.dispatchers.WorldExecutor +import java.util.UUID + +/** + * Adapts a Hytale [World] to the Hytale-free [WorldExecutor] abstraction used + * by `:core` and `:ecs`. + * + * Note: Hytale's `World` does not expose a direct `getUuid()`. The world UUID + * lives on its config — `world.worldConfig.uuid`. We resolve it once at construction. + * + * @ThreadSafe + */ +internal class HytaleWorldExecutor(private val world: World) : WorldExecutor { + override val worldId: UUID = world.worldConfig.uuid + override fun isAlive(): Boolean = world.isAlive + override fun execute(task: Runnable) = world.execute(task) + override fun toString(): String = + "HytaleWorldExecutor(${runCatching { world.name }.getOrDefault("?")}, $worldId)" +} + +/** Wraps this Hytale [World] as a [WorldExecutor]. */ +fun World.asExecutor(): WorldExecutor = HytaleWorldExecutor(this)