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