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:
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user