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