feat(examples): four runnable plugin examples

- player-load: load-on-join (PlayerReadyEvent → IO → modify).
- async-moderation: IAsyncEvent → coroutine bridge for chat moderation.
- periodic-leaderboard: pluginScope loop with parallel reads.
- bounty-board: kitchen-sink demo exercising every v0.1 primitive.
This commit is contained in:
2026-04-28 16:30:39 +02:00
parent ad1379c267
commit 5fc3bda1c5
22 changed files with 1069 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
# Examples
Four runnable plugin sketches built as Gradle composite builds — they
consume the in-development library directly via `includeBuild("../..")`,
so editing the lib and rebuilding an example picks up the change without
publishing.
| Example | Pattern |
|---|---|
| [`player-load/`](player-load/) | Load player JSON on `PlayerReadyEvent`, mutate a component on the world thread. |
| [`async-moderation/`](async-moderation/) | `IAsyncEvent` (`PlayerChatEvent`) bridged to coroutines, off-thread check, conditional `modify` + event cancel. |
| [`periodic-leaderboard/`](periodic-leaderboard/) | Plugin-scoped periodic loop with parallel `read` over players. |
| [`bounty-board/`](bounty-board/) | The kitchen sink — exercises every public Async primitive. |
## Build any of them
```bash
cd examples/<name>
./gradlew shadowJar
# → build/libs/<name>.jar
```
Drop the JAR in your dev server's `mods/`.
## How the composite wiring works
Each example's `settings.gradle.kts` does:
```kotlin
includeBuild("../..") {
dependencySubstitution {
substitute(module("com.mythlane:async"))
.using(project(":dist"))
}
}
```
So `implementation("com.mythlane:async:0.1.0-SNAPSHOT")` resolves to the
local `:dist` project — no Maven publication required during dev.
## What's stubbed
Each example contains `TODO()` markers in two places:
1. **Custom `Component<EntityStore>` registration.** Declaring `Wallet`,
`PlayerStats`, etc. on `EntityStore.REGISTRY` is plugin-specific and not
part of what Async does. Async only consumes the resulting `ComponentType`
via `ComponentRegistry.register<T>(...)`.
2. **Online-player accessors** like `onlinePlayerRefs()` and
`broadcastToAllWorlds(...)`. These iterate `Universe.get()` /
`world.players` in whatever shape your plugin needs.
Wire those to your dev server and the examples become fully runnable. The
Async-side calls (`installAsync()`, `playerScope`, `modify<T>`,
`PlayerRef.toEntityHandle()`, etc.) are accurate as-shipped.
+36
View File
@@ -0,0 +1,36 @@
# async-moderation
Listens to `PlayerChatEvent` (an `IAsyncEvent`), runs a fake 200ms moderation
check off-thread, and on flagged messages bumps a `warnings` counter on the
world thread + cancels the event so it never reaches other players.
## What it shows
- `eventRegistry.registerAsyncGlobal(PlayerChatEvent::class.java) { future -> … }`
the global overload. `registerAsync` only matches `IAsyncEvent<Void>`, and
`PlayerChatEvent` isn't `Void`-keyed; the wrong overload compiles silently
and never fires.
- `scope.future { … }` from `kotlinx-coroutines-jdk8` — bridges
`CompletableFuture` ↔ coroutines without `runBlocking`.
- `withContext(AsyncDispatchers.HytaleIO) { … }` — for the simulated HTTP call.
- Conditional `modify<T>(...)` + `event.isCancelled = true` — the canonical
shape for any moderation/filter handler.
## Build
```bash
./gradlew shadowJar
# → build/libs/async-moderation.jar
```
## Run
1. Drop the JAR in `mods/`, wire `ModerationStatsBinding.componentType()` to
your registered component.
2. Type a message containing `badword`, `spam`, or `scam` in chat.
3. The message is suppressed; the sender's `ModerationStats.warnings`
increments on the world thread.
The example uses a private `CoroutineScope` to make the supervisor
relationship explicit. In a real plugin, `pluginScope(this)` is fine and
flows through `Async.shutdown()` cleanly.
@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.shadow)
}
group = "com.mythlane.example"
version = "0.1.0"
description = "Async example: async-moderation"
val hytaleServerVersion = libs.versions.hytaleServer.get()
dependencies {
compileOnly(libs.hytale.server)
implementation("com.mythlane:async:0.1.0-SNAPSHOT")
}
kotlin {
jvmToolchain(25)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_24) }
}
tasks.withType<JavaCompile>().configureEach { options.release.set(24) }
tasks.processResources {
filteringCharset = Charsets.UTF_8.name()
val props = mapOf(
"version" to project.version,
"description" to (project.description ?: ""),
"hytaleServerVersion" to hytaleServerVersion,
)
inputs.properties(props)
filesMatching("manifest.json") { expand(props) }
}
tasks.shadowJar {
archiveBaseName.set(project.name)
archiveClassifier.set("")
}
tasks.jar { enabled = false }
tasks.build { dependsOn(tasks.shadowJar) }
@@ -0,0 +1,18 @@
rootProject.name = "async-moderation"
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://maven.hytale.com/release") { name = "hytale" }
}
versionCatalogs {
create("libs") { from(files("../../gradle/libs.versions.toml")) }
}
}
includeBuild("../..") {
dependencySubstitution {
substitute(module("com.mythlane:async"))
.using(project(":dist"))
}
}
@@ -0,0 +1,73 @@
package com.mythlane.example.moderation
import com.hypixel.hytale.server.core.event.events.player.PlayerChatEvent
import com.hypixel.hytale.server.core.plugin.JavaPlugin
import com.hypixel.hytale.server.core.plugin.JavaPluginInit
import com.mythlane.async.Async
import com.mythlane.async.dispatchers.AsyncDispatchers
import com.mythlane.async.ecs.ComponentRegistry
import com.mythlane.async.ecs.modify
import com.mythlane.async.binding.installAsync
import com.mythlane.async.binding.toEntityHandle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.future.future
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
private val FLAG_WORDS = setOf("badword", "spam", "scam")
/**
* Demonstrates the async-event flow:
* - `registerAsyncGlobal(PlayerChatEvent)` — `registerAsync` only matches
* `IAsyncEvent<Void>`, and `PlayerChatEvent` is `IAsyncEvent<String>`, so
* the global overload is required.
* - `scope.future { … }` — bridges Hytale's `CompletableFuture` contract to
* coroutines via `kotlinx-coroutines-jdk8`.
* - `withContext(AsyncDispatchers.HytaleIO)` for the simulated HTTP call.
* - Conditional `modify<T>` + `event.isCancelled = true`.
*
* Component-side wiring is left as `TODO` — orthogonal to the demo.
*/
class ModerationPlugin(init: JavaPluginInit) : JavaPlugin(init) {
// Plugin-scoped supervisor for the chat-handling coroutines bridged from
// CompletableFuture. Cancelled in shutdown via Async.shutdown.
private val scope = CoroutineScope(SupervisorJob() + AsyncDispatchers.HytaleIO)
override fun start() {
installAsync()
ComponentRegistry.register<ModerationStats>(ModerationStatsBinding.componentType())
eventRegistry.registerAsyncGlobal(PlayerChatEvent::class.java) { future ->
future.thenCompose { event ->
scope.future {
val flagged = withContext(AsyncDispatchers.HytaleIO) {
delay(200.milliseconds) // simulated HTTP call
FLAG_WORDS.any { it in event.content.lowercase() }
}
if (flagged) {
modify<ModerationStats, Unit>(event.sender.toEntityHandle()) {
warnings += 1
}
event.isCancelled = true
}
event
}
}
}
}
override fun shutdown() {
scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
Async.shutdown()
}
}
/** Stub component shape — replace with your real `Component<EntityStore>` subclass. */
class ModerationStats { var warnings: Int = 0 }
object ModerationStatsBinding {
fun componentType(): Any = TODO("Return EntityStore.REGISTRY.register(ModerationStats::class.java, ...) result here.")
}
@@ -0,0 +1,16 @@
{
"Group": "Mythlane",
"Name": "AsyncModeration",
"Version": "${version}",
"Description": "${description}",
"Authors": [
{ "Name": "Mythlane", "Email": "contact@mythlane.com", "Url": "https://mythlane.com" }
],
"Website": "https://mythlane.com",
"Main": "com.mythlane.example.moderation.ModerationPlugin",
"ServerVersion": "${hytaleServerVersion}",
"Dependencies": {},
"OptionalDependencies": {},
"DisabledByDefault": false,
"IncludesAssetPack": false
}
+71
View File
@@ -0,0 +1,71 @@
# bounty-board
The kitchen-sink example. A PvP bounty system that exercises every public
Async v0.1 primitive in one realistic plugin.
Players post bounties on each other (paying gold to put a price on a head).
On payout, the killer collects. A periodic public broadcast lists the top 5
active bounties; an optional Discord webhook fires on placements and
payouts; in-memory state writes to disk on shutdown (load on boot left to your plugin).
## What it shows
| Primitive | Where |
|---|---|
| `installAsync()` | `start()` |
| `Async.shutdown()` | `shutdown()` |
| `ComponentRegistry.register<T>(...)` | `start()`, twice |
| `playerScope(player)` | `registerJoinHook` |
| `pluginScope(this)` | chat handlers, broadcast loop, shutdown save |
| `worldScope(world)` | `startWorldDecayForKnownWorlds` |
| `WorldScopes.cancel(uuid)` | `onWorldUnload` |
| `Player.handle()` / `PlayerRef.toEntityHandle()` | every `modify` / `read` |
| `withContext(AsyncDispatchers.HytaleIO)` | webhook + persistence |
| `delay(...)` (HytaleScheduled) | broadcast and decay loops |
| `read<T, R>` strict | `handlePayout` |
| `readOrNull<T, R>` | join, broadcast, wallet query |
| `modify<T>` Unit | bounty append, decay, payout |
| `modify<T, R>` returning Boolean | atomic gold deduction |
| `withTimeout(...)` | guards the world-death race in the broadcast loop |
| `ComponentNotFoundException` handling | `handlePayout` |
| `WorldClosedException` handling | `notifyDiscord` |
| `pluginScope.future { }` | every chat command |
| Parallel `read` via `async`/`awaitAll` | `broadcastTop5` |
## Commands
| Chat | Effect |
|---|---|
| `!bounty?` | Show your wallet. |
| `!bounty <player> <amount>` | Pay gold to put a bounty on someone. |
| `!payout <player>` | Admin demo: collect bounties from a target (stand-in for a kill hook — Hytale's v0.1 SDK doesn't expose `PlayerDeathEvent`). |
The `!` prefix matters: Hytale's `CommandManager` swallows every `/`-prefixed
message before `PlayerChatEvent` fires, so this example uses `!` to flow
through chat normally.
## Build
```bash
./gradlew shadowJar
# → build/libs/bounty-board.jar
```
## Run
1. Drop the JAR in `mods/`.
2. Wire the SDK stubs in `BountyPlugin.kt`:
- `onlinePlayerRefs()`, `onlinePlayerRefsIn(world)`, `knownWorlds()`,
`broadcastToAllWorlds(text)` — your dev server's accessors.
- `WalletBinding.componentType()`, `BountyStateBinding.componentType()`
register `Wallet` and `BountyState` on `EntityStore.REGISTRY` and
return the result.
3. Optionally set `BOUNTY_WEBHOOK_URL` in the server's env for Discord
notifications.
4. In game: `!bounty <other-player> 100` places, `!payout <other-player>`
collects, the top-5 broadcast appears every 60s.
The persistence layer (`BountyRepo`) is intentionally a one-line JSON
serializer for the demo. Real plugins should use `kotlinx.serialization` or
a database — the load-bearing pattern is
`withContext(HytaleIO) { Files.writeString(...) }`, not the JSON shape.
+40
View File
@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.shadow)
}
group = "com.mythlane.example"
version = "0.1.0"
description = "Async example: bounty-board"
val hytaleServerVersion = libs.versions.hytaleServer.get()
dependencies {
compileOnly(libs.hytale.server)
implementation("com.mythlane:async:0.1.0-SNAPSHOT")
}
kotlin {
jvmToolchain(25)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_24) }
}
tasks.withType<JavaCompile>().configureEach { options.release.set(24) }
tasks.processResources {
filteringCharset = Charsets.UTF_8.name()
val props = mapOf(
"version" to project.version,
"description" to (project.description ?: ""),
"hytaleServerVersion" to hytaleServerVersion,
)
inputs.properties(props)
filesMatching("manifest.json") { expand(props) }
}
tasks.shadowJar {
archiveBaseName.set(project.name)
archiveClassifier.set("")
}
tasks.jar { enabled = false }
tasks.build { dependsOn(tasks.shadowJar) }
+18
View File
@@ -0,0 +1,18 @@
rootProject.name = "bounty-board"
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://maven.hytale.com/release") { name = "hytale" }
}
versionCatalogs {
create("libs") { from(files("../../gradle/libs.versions.toml")) }
}
}
includeBuild("../..") {
dependencySubstitution {
substitute(module("com.mythlane:async"))
.using(project(":dist"))
}
}
@@ -0,0 +1,284 @@
package com.mythlane.example.bounty
import com.hypixel.hytale.server.core.Message
import com.hypixel.hytale.server.core.event.events.player.PlayerChatEvent
import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent
import com.hypixel.hytale.server.core.plugin.JavaPlugin
import com.hypixel.hytale.server.core.plugin.JavaPluginInit
import com.hypixel.hytale.server.core.universe.PlayerRef
import com.hypixel.hytale.server.core.universe.world.World
import com.mythlane.async.Async
import com.mythlane.async.dispatchers.AsyncDispatchers
import com.mythlane.async.ecs.ComponentRegistry
import com.mythlane.async.ecs.EntityHandle
import com.mythlane.async.ecs.modify
import com.mythlane.async.ecs.read
import com.mythlane.async.ecs.readOrNull
import com.mythlane.async.exception.ComponentNotFoundException
import com.mythlane.async.exception.WorldClosedException
import com.mythlane.async.binding.handle
import com.mythlane.async.binding.installAsync
import com.mythlane.async.binding.playerScope
import com.mythlane.async.binding.pluginScope
import com.mythlane.async.binding.toEntityHandle
import com.mythlane.async.binding.worldScope
import com.mythlane.async.scope.PlayerScopes
import com.mythlane.async.scope.WorldScopes
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.future.future
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.util.UUID
import kotlin.time.Duration.Companion.seconds
/**
* Kitchen-sink example exercising every public Async v0.1 primitive in one
* realistic plugin. See `examples/bounty-board/README.md` for the
* feature-by-feature mapping.
*
* Chat commands (the `!` prefix avoids Hytale's CommandManager, which
* intercepts every `/`-prefixed message before PlayerChatEvent fires):
* - `!bounty?` — show your wallet.
* - `!bounty <player> <amt>` — pay `<amt>` gold to put a price on `<player>`.
* - `!payout <player>` — admin demo: collect bounties on `<player>`
* (stand-in for a PvP-kill hook; no public
* PlayerDeathEvent in v0.1 SDK).
*/
class BountyPlugin(init: JavaPluginInit) : JavaPlugin(init) {
private val webhookUrl: String get() = System.getenv("BOUNTY_WEBHOOK_URL").orEmpty()
private val stateFile get() = dataDirectory.resolve("bounties.json")
private val httpClient: HttpClient by lazy { HttpClient.newHttpClient() }
override fun start() {
installAsync()
ComponentRegistry.register<Wallet>(WalletBinding.componentType())
ComponentRegistry.register<BountyState>(BountyStateBinding.componentType())
Files.createDirectories(dataDirectory)
registerJoinHook()
registerChatCommands()
startBroadcastLoop()
startWorldDecayForKnownWorlds()
}
override fun shutdown() {
// Drain the save synchronously before Async.shutdown nukes scopes.
val saveJob = pluginScope(this).launch {
withContext(AsyncDispatchers.HytaleIO) {
Files.writeString(stateFile, BountyRepo.serialize())
}
}
runCatching { runBlocking { saveJob.join() } }
Async.shutdown()
}
// ── (9) playerScope: per-player work, auto-cancelled on disconnect ──────────
private fun registerJoinHook() {
eventRegistry.registerGlobal(PlayerReadyEvent::class.java) { event ->
val player = event.player
playerScope(player).launch {
val gold = readOrNull<Wallet, Int>(player.handle()) { gold } ?: 0
player.sendMessage(text("Welcome. Wallet: $gold gold."))
}
}
}
// ── (16) IAsyncEvent → coroutine bridge via pluginScope.future ──────────────
private fun registerChatCommands() {
eventRegistry.registerAsyncGlobal(PlayerChatEvent::class.java) { future ->
future.thenCompose { event ->
pluginScope(this).future {
handleChat(event)
event
}
}
}
}
private suspend fun handleChat(event: PlayerChatEvent) {
val sender: PlayerRef = event.sender
val handle = sender.toEntityHandle()
val msg = event.content.trim()
when {
msg == "!bounty?" -> {
val gold = readOrNull<Wallet, Int>(handle) { gold } ?: 0
// PlayerRef.sendMessage(Message) confirmed present in Hytale 2026.03.26 SDK.
sender.sendMessage(text("Wallet: $gold gold."))
event.isCancelled = true
}
msg.startsWith("!bounty ") -> {
val parts = msg.removePrefix("!bounty ").trim().split(" ", limit = 2)
val targetName = parts.getOrNull(0).orEmpty()
val amount = parts.getOrNull(1)?.toIntOrNull() ?: 0
if (amount <= 0 || targetName.isEmpty()) {
sender.sendMessage(text("Usage: !bounty <player> <amount>"))
event.isCancelled = true; return
}
val target = onlinePlayerRefs().firstOrNull { it.username == targetName }
if (target == null) {
sender.sendMessage(text("No such player online: $targetName"))
event.isCancelled = true; return
}
// modify with Boolean return — atomic deduct on world thread.
val deducted = modify<Wallet, Boolean>(handle) {
if (gold >= amount) { gold -= amount; true } else false
}
if (!deducted) {
sender.sendMessage(text("Not enough gold."))
event.isCancelled = true; return
}
// Unit overload — append the bounty entry on the target.
modify<BountyState>(target.toEntityHandle()) {
bountiesOnMe = bountiesOnMe + Bounty(payerUuid = sender.uuid, amount = amount)
}
BountyRepo.recordPlacement(sender.uuid, target.uuid, amount)
notifyDiscord("${sender.username} placed $amount on ${target.username}")
sender.sendMessage(text("Bounty placed."))
event.isCancelled = true
}
msg.startsWith("!payout ") -> {
val targetName = msg.removePrefix("!payout ").trim()
val victim = onlinePlayerRefs().firstOrNull { it.username == targetName }
if (victim == null) {
sender.sendMessage(text("No such player.")); event.isCancelled = true; return
}
val victimHandle = victim.toEntityHandle()
// strict read with ComponentNotFoundException handling.
val payout = try {
read<BountyState, Int>(victimHandle) { bountiesOnMe.sumOf { it.amount } }
} catch (_: ComponentNotFoundException) { 0 }
if (payout == 0) {
sender.sendMessage(text("No bounty on ${victim.username}."))
event.isCancelled = true; return
}
// two atomic mutations on potentially two world threads.
modify<Wallet>(handle) { gold += payout }
modify<BountyState>(victimHandle) { bountiesOnMe = emptyList() }
BountyRepo.recordPayout(victim.uuid, sender.uuid, payout)
notifyDiscord("${sender.username} collected $payout from ${victim.username}")
sender.sendMessage(text("Collected $payout gold."))
event.isCancelled = true
}
}
}
// ── (10) pluginScope periodic loop + (17) parallel reads ────────────────────
private fun startBroadcastLoop() {
pluginScope(this).launch {
while (isActive) {
delay(60.seconds)
runCatching { broadcastTop5() }
}
}
}
private suspend fun broadcastTop5() = coroutineScope {
val refs = onlinePlayerRefs()
val ranked = refs.map { ref ->
async {
val total = withTimeout(5.seconds) { // race mitigation
readOrNull<BountyState, Int>(ref.toEntityHandle()) {
bountiesOnMe.sumOf { it.amount }
} ?: 0
}
ref to total
}
}.awaitAll()
.filter { it.second > 0 }
.sortedByDescending { it.second }
.take(5)
if (ranked.isNotEmpty()) {
broadcastToAllWorlds(
"=== Top bounties ===\n" + ranked.joinToString("\n") { "${it.first.username}${it.second}g" }
)
}
}
// ── (11) worldScope: per-world maintenance task ─────────────────────────────
private fun startWorldDecayForKnownWorlds() {
knownWorlds().forEach { world ->
worldScope(world).launch {
while (isActive) {
delay(60.seconds)
if (!world.isAlive) break
try {
onlinePlayerRefsIn(world).forEach { ref ->
runCatching {
modify<BountyState>(ref.toEntityHandle()) {
bountiesOnMe = bountiesOnMe
.map { it.copy(amount = (it.amount - 1).coerceAtLeast(0)) }
.filter { it.amount > 0 }
}
}
}
} catch (_: WorldClosedException) {
// Race: world died between isAlive() check and execute().
break
}
}
}
}
}
/** Manual unload hook — call when your world-management code unloads a world. */
fun onWorldUnload(worldUuid: UUID) {
WorldScopes.cancel(worldUuid)
// PlayerScopes for departed players are handled by installAsync's PlayerDisconnect hook,
// but if you have orphan UUIDs to clean up explicitly, PlayerScopes.cancel(uuid) works too:
// PlayerScopes.cancel(some uuid)
}
private suspend fun notifyDiscord(message: String) {
if (webhookUrl.isEmpty()) return
try {
withContext(AsyncDispatchers.HytaleIO) {
httpClient.send(
HttpRequest.newBuilder(URI(webhookUrl))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("""{"content":"$message"}"""))
.build(),
HttpResponse.BodyHandlers.discarding(),
)
}
} catch (_: WorldClosedException) {
// Server shutting down mid-send.
} catch (_: Exception) {
// Webhook failures shouldn't crash gameplay.
}
}
private fun text(s: String): Message = Message.empty().insert(s)
// ── SDK-side stubs (orthogonal to the Async demo) ─────────────────────
private fun onlinePlayerRefs(): List<PlayerRef> =
TODO("Wire to your dev server's online accessor (e.g. iterate HytaleServer.get() worlds → world.players.values).")
private fun onlinePlayerRefsIn(world: World): List<PlayerRef> =
TODO("Wire to world.players.values.")
private fun knownWorlds(): List<World> =
TODO("Wire to the worlds your plugin cares about.")
private fun broadcastToAllWorlds(text: String): Unit =
TODO("Wire to your messaging API (per-world iterate + sendMessage).")
}
@@ -0,0 +1,41 @@
package com.mythlane.example.bounty
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** Stub component shapes — replace with real `Component<EntityStore>` subclasses in your plugin. */
class Wallet { var gold: Int = 0 }
class BountyState { var bountiesOnMe: List<Bounty> = emptyList() }
data class Bounty(val payerUuid: UUID, val amount: Int)
/** Stubs that return the registered `ComponentType<EntityStore, T>` from your setup. */
object WalletBinding {
fun componentType(): Any = TODO("Return EntityStore.REGISTRY.register(Wallet::class.java, ...) result.")
}
object BountyStateBinding {
fun componentType(): Any = TODO("Return EntityStore.REGISTRY.register(BountyState::class.java, ...) result.")
}
/**
* Tiny in-memory ledger so the example has something to persist on shutdown
* besides the live ECS components. Replace with your own persistence layer.
*/
object BountyRepo {
private val placements = ConcurrentHashMap<UUID, MutableList<Pair<UUID, Int>>>()
fun recordPlacement(payer: UUID, target: UUID, amount: Int) {
placements.computeIfAbsent(target) { mutableListOf() }.add(payer to amount)
}
fun recordPayout(victim: UUID, killer: UUID, amount: Int) {
placements.remove(victim) // bounty resolved; clear ledger row
// killer credit is stored on the live Wallet component, not here.
@Suppress("UNUSED_PARAMETER") killer
@Suppress("UNUSED_PARAMETER") amount
}
fun serialize(): String = placements.entries.joinToString(prefix = "{", postfix = "}") { (target, list) ->
""""$target":[${list.joinToString { "[\"${it.first}\",${it.second}]" }}]"""
}
}
@@ -0,0 +1,16 @@
{
"Group": "Mythlane",
"Name": "BountyBoard",
"Version": "${version}",
"Description": "${description}",
"Authors": [
{ "Name": "Mythlane", "Email": "contact@mythlane.com", "Url": "https://mythlane.com" }
],
"Website": "https://mythlane.com",
"Main": "com.mythlane.example.bounty.BountyPlugin",
"ServerVersion": "${hytaleServerVersion}",
"Dependencies": {},
"OptionalDependencies": {},
"DisabledByDefault": false,
"IncludesAssetPack": false
}
+37
View File
@@ -0,0 +1,37 @@
# periodic-leaderboard
Recomputes a top-5 leaderboard every minute by reading `PlayerStats.level`
from every online player in parallel, sorting, and broadcasting.
## What it shows
- `pluginScope(this).launch { while (isActive) { delay(60.seconds); … } }`
a plugin-lifetime periodic task. Cancels cleanly on shutdown via
`Async.shutdown()`.
- Parallel `read` over players via `async { … }.awaitAll()`. Each `read`
switches to its player's owning world dispatcher independently — reads on
different worlds run concurrently, reads on the same world serialize.
- `runCatching { … }` around the body so a single bad read doesn't kill the
loop.
This is the shape for any plugin-wide background recompute: leaderboards,
periodic backups of derived state, quest sync, etc.
## Build
```bash
./gradlew shadowJar
# → build/libs/periodic-leaderboard.jar
```
## Run
1. Drop in `mods/`, wire `PlayerStatsBinding.componentType()` and
`onlinePlayers()`.
2. For a faster demo while testing, change `delay(60.seconds)` to
`delay(5.seconds)` and rebuild.
3. Connect 2+ players. The top-5 logs every loop tick.
If you want **per-world** leaderboards instead, swap `pluginScope(this)` for
`worldScope(world)` and run one loop per world. Cancellation flows through
`WorldScopes.cancel(uuid)`.
@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.shadow)
}
group = "com.mythlane.example"
version = "0.1.0"
description = "Async example: periodic-leaderboard"
val hytaleServerVersion = libs.versions.hytaleServer.get()
dependencies {
compileOnly(libs.hytale.server)
implementation("com.mythlane:async:0.1.0-SNAPSHOT")
}
kotlin {
jvmToolchain(25)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_24) }
}
tasks.withType<JavaCompile>().configureEach { options.release.set(24) }
tasks.processResources {
filteringCharset = Charsets.UTF_8.name()
val props = mapOf(
"version" to project.version,
"description" to (project.description ?: ""),
"hytaleServerVersion" to hytaleServerVersion,
)
inputs.properties(props)
filesMatching("manifest.json") { expand(props) }
}
tasks.shadowJar {
archiveBaseName.set(project.name)
archiveClassifier.set("")
}
tasks.jar { enabled = false }
tasks.build { dependsOn(tasks.shadowJar) }
@@ -0,0 +1,18 @@
rootProject.name = "periodic-leaderboard"
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://maven.hytale.com/release") { name = "hytale" }
}
versionCatalogs {
create("libs") { from(files("../../gradle/libs.versions.toml")) }
}
}
includeBuild("../..") {
dependencySubstitution {
substitute(module("com.mythlane:async"))
.using(project(":dist"))
}
}
@@ -0,0 +1,64 @@
package com.mythlane.example.leaderboard
import com.hypixel.hytale.server.core.entity.entities.Player
import com.hypixel.hytale.server.core.plugin.JavaPlugin
import com.hypixel.hytale.server.core.plugin.JavaPluginInit
import com.mythlane.async.Async
import com.mythlane.async.ecs.ComponentRegistry
import com.mythlane.async.ecs.read
import com.mythlane.async.binding.handle
import com.mythlane.async.binding.installAsync
import com.mythlane.async.binding.pluginScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
/**
* Demonstrates: `pluginScope(this).launch { while (isActive) { delay(…); … } }`
* for a periodic task, plus parallel `read<T>` via `async { … }.awaitAll()`.
*
* SDK-side wiring of `onlinePlayers()` and the component type is left as `TODO`.
*/
class LeaderboardPlugin(init: JavaPluginInit) : JavaPlugin(init) {
override fun start() {
installAsync()
ComponentRegistry.register<PlayerStats>(PlayerStatsBinding.componentType())
pluginScope(this).launch {
while (isActive) {
delay(60.seconds)
runCatching { recompute() }
}
}
}
override fun shutdown() {
Async.shutdown()
}
private suspend fun recompute() = coroutineScope {
val players: List<Player> = onlinePlayers()
players.map { p ->
async {
p to read<PlayerStats, Int>(p.handle()) { level }
}
}.awaitAll()
.sortedByDescending { it.second }
.take(5)
// .also { broadcast(...) } — wire to your messaging API
}
private fun onlinePlayers(): List<Player> = TODO("Return Players from your dev server (e.g. HytaleServer.get().worlds...).")
}
/** Stub component shape — replace with your real `Component<EntityStore>` subclass. */
class PlayerStats { var level: Int = 1 }
object PlayerStatsBinding {
fun componentType(): Any = TODO("Return EntityStore.REGISTRY.register(PlayerStats::class.java, ...) result here.")
}
@@ -0,0 +1,16 @@
{
"Group": "Mythlane",
"Name": "PeriodicLeaderboard",
"Version": "${version}",
"Description": "${description}",
"Authors": [
{ "Name": "Mythlane", "Email": "contact@mythlane.com", "Url": "https://mythlane.com" }
],
"Website": "https://mythlane.com",
"Main": "com.mythlane.example.leaderboard.LeaderboardPlugin",
"ServerVersion": "${hytaleServerVersion}",
"Dependencies": {},
"OptionalDependencies": {},
"DisabledByDefault": false,
"IncludesAssetPack": false
}
+36
View File
@@ -0,0 +1,36 @@
# player-load
Loads `<plugin data dir>/players/<uuid>.json` when a player joins, hydrates a
`PlayerStats` component on the world thread.
## What it shows
- `eventRegistry.registerGlobal(PlayerReadyEvent::class.java) { … }` — the right
overload for events with a non-`Void` key type.
- `playerScope(player).launch { … }` — coroutine bound to the player. Cancels
on disconnect, so a slow disk read doesn't outlive the session.
- `withContext(AsyncDispatchers.HytaleIO) { … }` — file I/O off the world thread.
- `modify<PlayerStats>(player.handle()) { … }` — thread-safe component
mutation. The dispatcher switches happen automatically.
## Build
```bash
./gradlew shadowJar
# → build/libs/player-load.jar
```
## Run
1. Drop the JAR in your dev server's `mods/`.
2. Wire `PlayerStatsBinding.componentType()` — replace the `TODO()` with
whatever `EntityStore.REGISTRY.register(PlayerStats::class.java, …)` returns
in your setup.
3. Seed `<server>/data/Mythlane.PlayerLoad/players/<your-uuid>.json`:
```json
{"level":42,"clan":"red"}
```
4. Connect. The values land on the live `PlayerStats` component.
The JSON parser in the example is intentionally minimal — use
`kotlinx.serialization` in real plugins.
+40
View File
@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.shadow)
}
group = "com.mythlane.example"
version = "0.1.0"
description = "Async example: player-load"
val hytaleServerVersion = libs.versions.hytaleServer.get()
dependencies {
compileOnly(libs.hytale.server)
implementation("com.mythlane:async:0.1.0-SNAPSHOT")
}
kotlin {
jvmToolchain(25)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_24) }
}
tasks.withType<JavaCompile>().configureEach { options.release.set(24) }
tasks.processResources {
filteringCharset = Charsets.UTF_8.name()
val props = mapOf(
"version" to project.version,
"description" to (project.description ?: ""),
"hytaleServerVersion" to hytaleServerVersion,
)
inputs.properties(props)
filesMatching("manifest.json") { expand(props) }
}
tasks.shadowJar {
archiveBaseName.set(project.name)
archiveClassifier.set("")
}
tasks.jar { enabled = false }
tasks.build { dependsOn(tasks.shadowJar) }
+18
View File
@@ -0,0 +1,18 @@
rootProject.name = "player-load"
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://maven.hytale.com/release") { name = "hytale" }
}
versionCatalogs {
create("libs") { from(files("../../gradle/libs.versions.toml")) }
}
}
includeBuild("../..") {
dependencySubstitution {
substitute(module("com.mythlane:async"))
.using(project(":dist"))
}
}
@@ -0,0 +1,75 @@
package com.mythlane.example.playerload
import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent
import com.hypixel.hytale.server.core.plugin.JavaPlugin
import com.hypixel.hytale.server.core.plugin.JavaPluginInit
import com.mythlane.async.Async
import com.mythlane.async.dispatchers.AsyncDispatchers
import com.mythlane.async.ecs.ComponentRegistry
import com.mythlane.async.ecs.modify
import com.mythlane.async.binding.handle
import com.mythlane.async.binding.installAsync
import com.mythlane.async.binding.playerScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.nio.file.Files
import java.util.UUID
/**
* Demonstrates: `playerScope(...).launch { withContext(HytaleIO) { … }; modify<T> { … } }`.
*
* SDK-side wiring (component type registration, the actual `PlayerStats` class)
* is left as `TODO` — those pieces depend on your dev server's component layout
* and are orthogonal to what this example demonstrates.
*/
class PlayerLoadPlugin(init: JavaPluginInit) : JavaPlugin(init) {
override fun start() {
installAsync()
ComponentRegistry.register<PlayerStats>(PlayerStatsBinding.componentType())
val playersDir = dataDirectory.resolve("players").also { Files.createDirectories(it) }
eventRegistry.registerGlobal(PlayerReadyEvent::class.java) { event ->
val player = event.player
playerScope(player).launch {
val data = withContext(AsyncDispatchers.HytaleIO) {
loadFromDisk(playersDir, player.uuid!!)
}
modify<PlayerStats>(player.handle()) {
level = data.level
clan = data.clan
}
}
}
}
override fun shutdown() {
Async.shutdown()
}
// Demo-grade JSON parser — use kotlinx.serialization in real plugins.
private fun loadFromDisk(dir: java.nio.file.Path, uuid: UUID): PlayerData {
val file = dir.resolve("$uuid.json")
if (!Files.exists(file)) return PlayerData(level = 1, clan = "")
val raw = Files.readString(file).trim().removePrefix("{").removeSuffix("}")
val map = raw.split(",").associate {
val (k, v) = it.split(":", limit = 2)
k.trim().trim('"') to v.trim().trim('"')
}
return PlayerData(map["level"]?.toIntOrNull() ?: 1, map["clan"].orEmpty())
}
}
data class PlayerData(val level: Int, val clan: String)
/** Stub component shape — replace with your real `Component<EntityStore>` subclass. */
class PlayerStats {
var level: Int = 1
var clan: String = ""
}
/** Stub: returns the registered `ComponentType<EntityStore, PlayerStats>` token from your setup. */
object PlayerStatsBinding {
fun componentType(): Any = TODO("Return EntityStore.REGISTRY.register(PlayerStats::class.java, ...) result here.")
}
@@ -0,0 +1,16 @@
{
"Group": "Mythlane",
"Name": "PlayerLoad",
"Version": "${version}",
"Description": "${description}",
"Authors": [
{ "Name": "Mythlane", "Email": "contact@mythlane.com", "Url": "https://mythlane.com" }
],
"Website": "https://mythlane.com",
"Main": "com.mythlane.example.playerload.PlayerLoadPlugin",
"ServerVersion": "${hytaleServerVersion}",
"Dependencies": {},
"OptionalDependencies": {},
"DisabledByDefault": false,
"IncludesAssetPack": false
}