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.
This commit is contained in:
2026-04-28 16:30:16 +02:00
parent 1870aeea12
commit f23bb2178f
6 changed files with 198 additions and 0 deletions
+48
View File
@@ -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<EntityStore>.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<T>(...) { ... } }`
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.
+16
View File
@@ -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)
}
@@ -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<EntityStore>` to the [EntityHandle] abstraction.
*
* The [typeKey] passed to [get] is the opaque `Any` token registered via
* `ComponentRegistry.register<T>(componentType)` — at runtime it must be a
* `ComponentType<EntityStore, T>`.
*
* Resolving the owning [World] from a bare `Ref<EntityStore>` 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<EntityStore>,
override val world: WorldExecutor,
) : EntityHandle {
@Suppress("UNCHECKED_CAST")
override fun <T : Any> get(typeKey: Any): T? {
val componentType = typeKey as ComponentType<EntityStore, *>
return ref.store.getComponent(ref, componentType as ComponentType<EntityStore, Nothing>) as T?
}
}
/** Wraps this `Ref<EntityStore>` as an [EntityHandle] using the given [world]. */
fun Ref<EntityStore>.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)
}
@@ -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()
@@ -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)
}
}
@@ -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)