docs: top-level README
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/Kotlin-2.2-7f52ff" alt="Kotlin 2.2"/>
|
||||||
|
<img src="https://img.shields.io/badge/JVM-25-orange" alt="JDK 25"/>
|
||||||
|
<img src="https://img.shields.io/badge/Gradle-Shadow-green" alt="Gradle Shadow"/>
|
||||||
|
<img src="https://img.shields.io/badge/Hytale-2026.03.26-blueviolet" alt="Hytale 2026.03.26"/>
|
||||||
|
<img src="https://img.shields.io/badge/v0.1-prerelease-orange" alt="v0.1 prerelease"/>
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-lightgrey" alt="MIT"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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<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:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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
|
||||||
|
|
||||||
|
```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<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.
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `: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<PlayerStats>(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<ModerationStats>(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<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/`](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 <HytaleServer>/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<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](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.
|
||||||
Reference in New Issue
Block a user