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:
@@ -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.
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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) }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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) }
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user