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
+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
}