From eb37c11e13c74f0dcff5caec40b8f2c3c74962fc Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Tue, 28 Apr 2026 16:30:48 +0200 Subject: [PATCH] docs: top-level README --- README.md | 347 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b01d9c4 --- /dev/null +++ b/README.md @@ -0,0 +1,347 @@ +# 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: + +```java +CompletableFuture.runAsync(() -> { + PlayerData data = database.load(uuid); + world.execute(() -> { + Ref ref = player.getRef(); + Store 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: + +```kotlin +playerScope(player).launch { + val data = withContext(AsyncDispatchers.HytaleIO) { database.load(uuid) } + modify(player.handle()) { level = data.level } +} +``` + +Same thread choreography. Cancellation on disconnect comes for free. + +--- + +## Setup + +```kotlin +class MyPlugin(init: JavaPluginInit) : JavaPlugin(init) { + override fun start() { + installAsync() + // Register each component type once, with whatever ComponentType + // your EntityStore.REGISTRY.register(...) call returned at SDK setup time. + ComponentRegistry.register(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 + +```kotlin +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 + +```kotlin +// Register each component type once, with the ComponentType your EntityStore +// registered at SDK setup time: +ComponentRegistry.register(yourPlayerStatsComponentType) + +read(entity) { … } // throws if component missing +readOrNull(entity) { … } // returns null instead + +modify(entity) { … } // Unit +modify(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. + +```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 +``` + +### `: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. + +```kotlin +eventRegistry.registerGlobal(PlayerReadyEvent::class.java) { event -> + val player = event.player + playerScope(player).launch { + val data = withContext(AsyncDispatchers.HytaleIO) { loadFromDisk(player.uuid) } + modify(player.handle()) { + level = data.level + clan = data.clan + } + } +} +``` + +**Async chat moderation.** `IAsyncEvent` bridge via `pluginScope.future { … }`, +HTTP off-thread, mutate-and-cancel inline. + +```kotlin +eventRegistry.registerAsyncGlobal(PlayerChatEvent::class.java) { future -> + future.thenCompose { event -> + pluginScope(this).future { + val flagged = withContext(AsyncDispatchers.HytaleIO) { + moderationApi.check(event.content) + } + if (flagged) { + modify(event.sender.toEntityHandle()) { warnings += 1 } + event.isCancelled = true + } + event + } + } +} +``` + +**Periodic leaderboard.** Plugin-scoped loop, parallel reads across players. + +```kotlin +pluginScope(this).launch { + while (isActive) { + delay(60.seconds) + val top = onlinePlayers().map { p -> + async { p to read(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/`](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](https://github.com/briarss/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 + +```bash +./gradlew :dist:shadowJar +cp dist/build/libs/async-*.jar /Server/mods/ +# Restart the server +``` + +Requires JDK 25, Gradle 9.4+, Hytale Server `2026.03.26-89796e57b` or newer. + +--- + +## Building from source + +```bash +./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(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](https://mythlane.com). Module layout influenced by [Kytale](https://github.com/briarss/Kytale). + +--- + +## License + +MIT — see [LICENSE](LICENSE). Free to fork, modify, and use commercially.