docs: top-level README

This commit is contained in:
2026-04-28 16:30:48 +02:00
parent 5fc3bda1c5
commit eb37c11e13
+347
View File
@@ -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.