From 5fc3bda1c5b305427b7a452b69c791978159e459 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Tue, 28 Apr 2026 16:30:39 +0200 Subject: [PATCH] feat(examples): four runnable plugin examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- examples/README.md | 56 ++++ examples/async-moderation/README.md | 36 +++ examples/async-moderation/build.gradle.kts | 40 +++ examples/async-moderation/settings.gradle.kts | 18 ++ .../example/moderation/ModerationPlugin.kt | 73 +++++ .../src/main/resources/manifest.json | 16 + examples/bounty-board/README.md | 71 +++++ examples/bounty-board/build.gradle.kts | 40 +++ examples/bounty-board/settings.gradle.kts | 18 ++ .../mythlane/example/bounty/BountyPlugin.kt | 284 ++++++++++++++++++ .../com/mythlane/example/bounty/Components.kt | 41 +++ .../src/main/resources/manifest.json | 16 + examples/periodic-leaderboard/README.md | 37 +++ .../periodic-leaderboard/build.gradle.kts | 40 +++ .../periodic-leaderboard/settings.gradle.kts | 18 ++ .../example/leaderboard/LeaderboardPlugin.kt | 64 ++++ .../src/main/resources/manifest.json | 16 + examples/player-load/README.md | 36 +++ examples/player-load/build.gradle.kts | 40 +++ examples/player-load/settings.gradle.kts | 18 ++ .../example/playerload/PlayerLoadPlugin.kt | 75 +++++ .../src/main/resources/manifest.json | 16 + 22 files changed, 1069 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/async-moderation/README.md create mode 100644 examples/async-moderation/build.gradle.kts create mode 100644 examples/async-moderation/settings.gradle.kts create mode 100644 examples/async-moderation/src/main/kotlin/com/mythlane/example/moderation/ModerationPlugin.kt create mode 100644 examples/async-moderation/src/main/resources/manifest.json create mode 100644 examples/bounty-board/README.md create mode 100644 examples/bounty-board/build.gradle.kts create mode 100644 examples/bounty-board/settings.gradle.kts create mode 100644 examples/bounty-board/src/main/kotlin/com/mythlane/example/bounty/BountyPlugin.kt create mode 100644 examples/bounty-board/src/main/kotlin/com/mythlane/example/bounty/Components.kt create mode 100644 examples/bounty-board/src/main/resources/manifest.json create mode 100644 examples/periodic-leaderboard/README.md create mode 100644 examples/periodic-leaderboard/build.gradle.kts create mode 100644 examples/periodic-leaderboard/settings.gradle.kts create mode 100644 examples/periodic-leaderboard/src/main/kotlin/com/mythlane/example/leaderboard/LeaderboardPlugin.kt create mode 100644 examples/periodic-leaderboard/src/main/resources/manifest.json create mode 100644 examples/player-load/README.md create mode 100644 examples/player-load/build.gradle.kts create mode 100644 examples/player-load/settings.gradle.kts create mode 100644 examples/player-load/src/main/kotlin/com/mythlane/example/playerload/PlayerLoadPlugin.kt create mode 100644 examples/player-load/src/main/resources/manifest.json diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..d796d9c --- /dev/null +++ b/examples/README.md @@ -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/ +./gradlew shadowJar +# → build/libs/.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` 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(...)`. + +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`, +`PlayerRef.toEntityHandle()`, etc.) are accurate as-shipped. diff --git a/examples/async-moderation/README.md b/examples/async-moderation/README.md new file mode 100644 index 0000000..b4bfaf8 --- /dev/null +++ b/examples/async-moderation/README.md @@ -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`, 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(...)` + `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. diff --git a/examples/async-moderation/build.gradle.kts b/examples/async-moderation/build.gradle.kts new file mode 100644 index 0000000..c0d9e39 --- /dev/null +++ b/examples/async-moderation/build.gradle.kts @@ -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().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) } diff --git a/examples/async-moderation/settings.gradle.kts b/examples/async-moderation/settings.gradle.kts new file mode 100644 index 0000000..072b643 --- /dev/null +++ b/examples/async-moderation/settings.gradle.kts @@ -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")) + } +} diff --git a/examples/async-moderation/src/main/kotlin/com/mythlane/example/moderation/ModerationPlugin.kt b/examples/async-moderation/src/main/kotlin/com/mythlane/example/moderation/ModerationPlugin.kt new file mode 100644 index 0000000..9cef886 --- /dev/null +++ b/examples/async-moderation/src/main/kotlin/com/mythlane/example/moderation/ModerationPlugin.kt @@ -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`, and `PlayerChatEvent` is `IAsyncEvent`, 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` + `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(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(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` subclass. */ +class ModerationStats { var warnings: Int = 0 } + +object ModerationStatsBinding { + fun componentType(): Any = TODO("Return EntityStore.REGISTRY.register(ModerationStats::class.java, ...) result here.") +} diff --git a/examples/async-moderation/src/main/resources/manifest.json b/examples/async-moderation/src/main/resources/manifest.json new file mode 100644 index 0000000..b903165 --- /dev/null +++ b/examples/async-moderation/src/main/resources/manifest.json @@ -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 +} diff --git a/examples/bounty-board/README.md b/examples/bounty-board/README.md new file mode 100644 index 0000000..75fbbfe --- /dev/null +++ b/examples/bounty-board/README.md @@ -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(...)` | `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` strict | `handlePayout` | +| `readOrNull` | join, broadcast, wallet query | +| `modify` Unit | bounty append, decay, payout | +| `modify` 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 ` | Pay gold to put a bounty on someone. | +| `!payout ` | 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 100` places, `!payout ` + 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. diff --git a/examples/bounty-board/build.gradle.kts b/examples/bounty-board/build.gradle.kts new file mode 100644 index 0000000..8f43755 --- /dev/null +++ b/examples/bounty-board/build.gradle.kts @@ -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().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) } diff --git a/examples/bounty-board/settings.gradle.kts b/examples/bounty-board/settings.gradle.kts new file mode 100644 index 0000000..403cd8b --- /dev/null +++ b/examples/bounty-board/settings.gradle.kts @@ -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")) + } +} diff --git a/examples/bounty-board/src/main/kotlin/com/mythlane/example/bounty/BountyPlugin.kt b/examples/bounty-board/src/main/kotlin/com/mythlane/example/bounty/BountyPlugin.kt new file mode 100644 index 0000000..e26e844 --- /dev/null +++ b/examples/bounty-board/src/main/kotlin/com/mythlane/example/bounty/BountyPlugin.kt @@ -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 ` — pay `` gold to put a price on ``. + * - `!payout ` — admin demo: collect bounties on `` + * (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(WalletBinding.componentType()) + ComponentRegistry.register(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(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(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 ")) + 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(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(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(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(handle) { gold += payout } + modify(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(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(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 = + TODO("Wire to your dev server's online accessor (e.g. iterate HytaleServer.get() worlds → world.players.values).") + private fun onlinePlayerRefsIn(world: World): List = + TODO("Wire to world.players.values.") + private fun knownWorlds(): List = + 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).") +} diff --git a/examples/bounty-board/src/main/kotlin/com/mythlane/example/bounty/Components.kt b/examples/bounty-board/src/main/kotlin/com/mythlane/example/bounty/Components.kt new file mode 100644 index 0000000..5be1523 --- /dev/null +++ b/examples/bounty-board/src/main/kotlin/com/mythlane/example/bounty/Components.kt @@ -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` subclasses in your plugin. */ +class Wallet { var gold: Int = 0 } +class BountyState { var bountiesOnMe: List = emptyList() } + +data class Bounty(val payerUuid: UUID, val amount: Int) + +/** Stubs that return the registered `ComponentType` 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>>() + + 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}]" }}]""" + } +} diff --git a/examples/bounty-board/src/main/resources/manifest.json b/examples/bounty-board/src/main/resources/manifest.json new file mode 100644 index 0000000..c236da5 --- /dev/null +++ b/examples/bounty-board/src/main/resources/manifest.json @@ -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 +} diff --git a/examples/periodic-leaderboard/README.md b/examples/periodic-leaderboard/README.md new file mode 100644 index 0000000..a02881d --- /dev/null +++ b/examples/periodic-leaderboard/README.md @@ -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)`. diff --git a/examples/periodic-leaderboard/build.gradle.kts b/examples/periodic-leaderboard/build.gradle.kts new file mode 100644 index 0000000..d5b3f43 --- /dev/null +++ b/examples/periodic-leaderboard/build.gradle.kts @@ -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().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) } diff --git a/examples/periodic-leaderboard/settings.gradle.kts b/examples/periodic-leaderboard/settings.gradle.kts new file mode 100644 index 0000000..3910c25 --- /dev/null +++ b/examples/periodic-leaderboard/settings.gradle.kts @@ -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")) + } +} diff --git a/examples/periodic-leaderboard/src/main/kotlin/com/mythlane/example/leaderboard/LeaderboardPlugin.kt b/examples/periodic-leaderboard/src/main/kotlin/com/mythlane/example/leaderboard/LeaderboardPlugin.kt new file mode 100644 index 0000000..284cd89 --- /dev/null +++ b/examples/periodic-leaderboard/src/main/kotlin/com/mythlane/example/leaderboard/LeaderboardPlugin.kt @@ -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` 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(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 = onlinePlayers() + players.map { p -> + async { + p to read(p.handle()) { level } + } + }.awaitAll() + .sortedByDescending { it.second } + .take(5) + // .also { broadcast(...) } — wire to your messaging API + } + + private fun onlinePlayers(): List = TODO("Return Players from your dev server (e.g. HytaleServer.get().worlds...).") +} + +/** Stub component shape — replace with your real `Component` subclass. */ +class PlayerStats { var level: Int = 1 } + +object PlayerStatsBinding { + fun componentType(): Any = TODO("Return EntityStore.REGISTRY.register(PlayerStats::class.java, ...) result here.") +} diff --git a/examples/periodic-leaderboard/src/main/resources/manifest.json b/examples/periodic-leaderboard/src/main/resources/manifest.json new file mode 100644 index 0000000..68bbc2f --- /dev/null +++ b/examples/periodic-leaderboard/src/main/resources/manifest.json @@ -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 +} diff --git a/examples/player-load/README.md b/examples/player-load/README.md new file mode 100644 index 0000000..079b113 --- /dev/null +++ b/examples/player-load/README.md @@ -0,0 +1,36 @@ +# player-load + +Loads `/players/.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(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 `/data/Mythlane.PlayerLoad/players/.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. diff --git a/examples/player-load/build.gradle.kts b/examples/player-load/build.gradle.kts new file mode 100644 index 0000000..83342c0 --- /dev/null +++ b/examples/player-load/build.gradle.kts @@ -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().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) } diff --git a/examples/player-load/settings.gradle.kts b/examples/player-load/settings.gradle.kts new file mode 100644 index 0000000..9531148 --- /dev/null +++ b/examples/player-load/settings.gradle.kts @@ -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")) + } +} diff --git a/examples/player-load/src/main/kotlin/com/mythlane/example/playerload/PlayerLoadPlugin.kt b/examples/player-load/src/main/kotlin/com/mythlane/example/playerload/PlayerLoadPlugin.kt new file mode 100644 index 0000000..e622c27 --- /dev/null +++ b/examples/player-load/src/main/kotlin/com/mythlane/example/playerload/PlayerLoadPlugin.kt @@ -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 { … } }`. + * + * 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(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(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` subclass. */ +class PlayerStats { + var level: Int = 1 + var clan: String = "" +} + +/** Stub: returns the registered `ComponentType` token from your setup. */ +object PlayerStatsBinding { + fun componentType(): Any = TODO("Return EntityStore.REGISTRY.register(PlayerStats::class.java, ...) result here.") +} diff --git a/examples/player-load/src/main/resources/manifest.json b/examples/player-load/src/main/resources/manifest.json new file mode 100644 index 0000000..5c891f4 --- /dev/null +++ b/examples/player-load/src/main/resources/manifest.json @@ -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 +}