Volver a Mods
    Async

    Async

    Stop fighting IllegalStateException: Assert not in thread!. Async gives Hytale plugins suspending ECS reads, off-thread I/O, and per-player/world/plugin scopes that cancel themselves when they should.

    Utility
    Library
    Quality of Life
    Miscellaneous

    Acerca de este Mod

    Async

    Coroutines for Hytale's per-world ECS. Replaces the noisy CompletableFuture.runAsync { world.execute { store.getComponent(…) } } pattern with one suspending call.

    Kotlin 2.2 JDK 25 Gradle Shadow Hytale 2026.03.26 v0.1 prerelease MIT


    The pain it solves

    Each Hytale world runs on its own thread. Touch a component from anywhere else and you get IllegalStateException: Assert not in thread!. Touch the database from the world thread and you freeze every player connected to it. Plugin code lives wedged between those two failure modes, and the workaround is the same boilerplate everywhere:

    CompletableFuture.runAsync(() -> {
        PlayerData data = database.load(uuid);
        world.execute(() -> {
            Ref<EntityStore> ref = player.getRef();
            Store<EntityStore> store = ref.getStore();
            PlayerStats stats = store.getComponent(ref, PlayerStats.getComponentType());
            stats.setLevel(data.level);
        });
    }).exceptionally(t -> { logger.error("load failed", t); return null; });
    

    Async collapses both halves into one suspending function:

    playerScope(player).launch {
        val data = withContext(AsyncDispatchers.HytaleIO) { database.load(uuid) }
        modify<PlayerStats>(player.handle()) { level = data.level }
    }
    

    Same thread choreography. Cancellation on disconnect comes for free.


    Setup

    class MyPlugin(init: JavaPluginInit) : JavaPlugin(init) {
        override fun start() {
            installAsync()
            // Register each component type once, with whatever ComponentType<EntityStore, T>
            // your EntityStore.REGISTRY.register(...) call returned at SDK setup time.
            ComponentRegistry.register<PlayerStats>(yourPlayerStatsComponentType)
        }
    
        override fun shutdown() {
            Async.shutdown()
        }
    }
    

    Two lines in start(), one in shutdown(). From there, playerScope(player), worldScope(world), pluginScope(this) are all fair game from any thread.


    API by module

    :core — dispatchers and scopes

    AsyncDispatchers.World(world)         // world's main thread
    AsyncDispatchers.HytaleIO             // bounded pool for blocking I/O
    AsyncDispatchers.HytaleScheduled      // backs delay() and withTimeout()
    
    PlayerScopes.of(uuid)
    PlayerScopes.cancel(uuid)
    PlayerScopes.cancelAll()
    
    WorldScopes.of(worldUuid)             // same shape
    PluginScopes.of(plugin)               // identity-keyed
    
    Async.shutdown()                      // cancels everything; idempotent
    

    Sealed AsyncException hierarchy: WorldClosedException, NoWorldInContextException, ComponentNotFoundException, ComponentTypeNotRegisteredException.

    :ecs — component DSL

    // Register each component type once, with the ComponentType your EntityStore
    // registered at SDK setup time:
    ComponentRegistry.register<PlayerStats>(yourPlayerStatsComponentType)
    
    read<T, R>(entity)       { … }    // throws if component missing
    readOrNull<T, R>(entity) { … }    // returns null instead
    
    modify<T>(entity)        { … }    // Unit
    modify<T, R>(entity)     { … }    // returns the block's value
    

    Every entry switches to the entity's world dispatcher, runs your block on the world thread, returns to the caller's dispatcher. Mutations on the component persist in place — there's no setComponent and no rollback (see the threading section).

    :binding — Hytale glue

    The only module that imports anything from the Hytale SDK.

    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
    

    :dist — what you ship

    No code. Bundles :core + :ecs + :binding into a single shaded JAR via com.gradleup.shadow. The only artifact a consumer ever drops in mods/.


    Three real patterns

    Load on join. PlayerReadyEvent → fetch off-thread → mutate on world thread.

    eventRegistry.registerGlobal(PlayerReadyEvent::class.java) { event ->
        val player = event.player
        playerScope(player).launch {
            val data = withContext(AsyncDispatchers.HytaleIO) { loadFromDisk(player.uuid) }
            modify<PlayerStats>(player.handle()) {
                level = data.level
                clan = data.clan
            }
        }
    }
    

    Async chat moderation. IAsyncEvent bridge via pluginScope.future { … }, HTTP off-thread, mutate-and-cancel inline.

    eventRegistry.registerAsyncGlobal(PlayerChatEvent::class.java) { future ->
        future.thenCompose { event ->
            pluginScope(this).future {
                val flagged = withContext(AsyncDispatchers.HytaleIO) {
                    moderationApi.check(event.content)
                }
                if (flagged) {
                    modify<ModerationStats>(event.sender.toEntityHandle()) { warnings += 1 }
                    event.isCancelled = true
                }
                event
            }
        }
    }
    

    Periodic leaderboard. Plugin-scoped loop, parallel reads across players.

    pluginScope(this).launch {
        while (isActive) {
            delay(60.seconds)
            val top = onlinePlayers().map { p ->
                async { p to read<PlayerStats, Int>(p.handle()) { level } }
            }.awaitAll().sortedByDescending { it.second }.take(5)
            broadcast(top)
        }
    }
    

    Each read switches to its player's world dispatcher independently — reads on different worlds happen in parallel, reads on the same world serialize naturally. Fully runnable versions in examples/.


    Modules

    Module Hytale SDK dep Purpose
    core none Dispatchers, scopes, exceptions. Testable without a server.
    ecs none The read / readOrNull / modify DSL.
    binding compileOnly Hytale-specific glue. The only module that imports com.hypixel.*.
    dist aggregator Single shaded JAR.

    The split exists so :core and :ecs stay testable against in-memory stubs in milliseconds, and so a future Hytale API change only ripples through :binding. For deployment, always ship :dist — the per-module split is for code hygiene, not cherry-picking.


    Threading model, briefly

    Three dispatchers map to three concerns:

    Dispatcher Backed by Use for
    AsyncDispatchers.World(world) world.execute(Runnable) component reads, writes
    AsyncDispatchers.HytaleIO bounded pool, Runtime.availableProcessors() × 2 blocking I/O — DB, HTTP, file
    AsyncDispatchers.HytaleScheduled ScheduledExecutorService backs delay() and withTimeout()

    Mutate in place. Hytale's Store exposes no public setComponent(ref, value). The component returned by getComponent is the live in-store instance, and mutating it is how you persist. There's no copy-on-read and no rollback if a modify block throws halfway through. Validate before mutating.

    Cancellation. Coroutines on a player/world/plugin scope are cancelled atomically when the scope is. Dispatchers.World honors cancellation before dispatch — a job cancelled while still in the queue is dropped. Once a runnable starts on the world thread, it runs to completion. The world is the single writer; ripping a thread mid-mutation would leave the store inconsistent.

    KDoc on every public symbol carries one of three plain-text tags: @ThreadSafe, @WorldThreadOnly, @AnyThread. Conventions, not annotations — zero runtime cost.


    Coexistence with Kytale

    Kytale ships a full Kotlin framework for Hytale plugins (KotlinPlugin base class, Event / Command / Config DSLs). Async solves a narrower problem — thread-safe ECS access — and works alongside Kytale or standalone. If you're already on Kytale, drop Async in just for the component DSL and keep the rest of your stack.


    Installing

    ./gradlew :dist:shadowJar
    cp dist/build/libs/async-*.jar <HytaleServer>/Server/mods/
    # Restart the server
    

    Requires JDK 25, Gradle 9.4+, Hytale Server 2026.03.26-89796e57b or newer.


    Building from source

    ./gradlew build               # all modules + tests
    ./gradlew :dist:shadowJar     # the shaded jar
    

    Tests for :core and :ecs run against in-memory stubs and don't need a Hytale server.

    Layout for contributors:

    • New dispatcher → core/dispatchers/AsyncDispatchers.kt
    • New scope kind → mirror PlayerScopes in core/scope/
    • New DSL primitive → ecs/ComponentDsl.kt. Stay suspending and dispatcher-aware.
    • New SDK adapter → binding/. Don't import com.hypixel.* from :core or :ecs.

    How a call flows

           (any thread)
                │
                ▼
       playerScope(player).launch
                │
                │   suspend
                ▼
       withContext(HytaleIO)  ──── blocking I/O, off-thread
                │
                │   suspend
                ▼
       modify<T>(player.handle())  ──── switches to world thread
                │
                ▼
       live component mutation
                │
                ▼
           (returns to caller's dispatcher)
    

    Status and known limits

    v0.1 ships dispatchers, the three scope registries, the suspending DSL, the Hytale binding, the shaded JAR, and four example sketches.

    Things to know:

    • No public WorldUnloadEvent in the current SDK. Cancel WorldScopes.cancel(uuid) manually from your world-management code until it ships.
    • PlayerDisconnectEvent fires twice on world unload. PlayerScopes.cancel is idempotent so this is benign — just be aware if you wire your own listener.
    • World-death race: a small window exists between world.isAlive() and world.execute() where a task can be silently dropped. Wrap long world-thread work in withTimeout(...) if it matters.

    Stack

    • Kotlin 2.2.20, target JVM 24, toolchain JDK 25 (will move to JVM 25 once Kotlin 2.3 ships).
    • kotlinx-coroutines 1.8 (core + jdk8 bridge).
    • Gradle 9.4 with version catalog and Kotlin DSL.
    • JUnit 5 + Kotest assertions + MockK for tests.
    • com.gradleup.shadow 9.3 for the fat JAR.
    • Hytale Plugin API (https://maven.hytale.com/release), compileOnly only in :binding.

    Credits

    By Mythlane. Module layout influenced by Kytale.


    License

    MIT — see LICENSE. Free to fork, modify, and use commercially.

    Información

    Descargas19
    Actualizado29 abr 2026
    Autores

    ¿Te gusta este mod?

    No olvides visitar la página oficial para dar soporte a los creadores.

    Ver en CurseForge