From a10294c01f1956db5f0eea9af055f37c708ff8d6 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Tue, 28 Apr 2026 16:29:55 +0200 Subject: [PATCH] feat(core): dispatchers, scopes, and exceptions HytaleDispatchers (World/HytaleIO/HytaleScheduled), per-key scope registries (PlayerScopes, WorldScopes, PluginScopes) keyed by UUID or plugin identity, and the sealed AsyncException hierarchy. Includes unit tests against in-memory stubs. --- core/README.md | 52 +++++++++++ core/build.gradle.kts | 19 ++++ .../kotlin/com/mythlane/async/HytaleAsync.kt | 24 +++++ .../async/dispatchers/HytaleDispatchers.kt | 92 +++++++++++++++++++ .../async/dispatchers/WorldExecutor.kt | 20 ++++ .../async/exception/HytaleAsyncException.kt | 26 ++++++ .../com/mythlane/async/scope/PlayerScope.kt | 49 ++++++++++ .../com/mythlane/async/scope/PluginScopes.kt | 54 +++++++++++ .../com/mythlane/async/scope/WorldScopes.kt | 45 +++++++++ .../kotlin/com/mythlane/async/ScopesTest.kt | 83 +++++++++++++++++ .../kotlin/com/mythlane/async/SmokeTest.kt | 77 ++++++++++++++++ 11 files changed, 541 insertions(+) create mode 100644 core/README.md create mode 100644 core/build.gradle.kts create mode 100644 core/src/main/kotlin/com/mythlane/async/HytaleAsync.kt create mode 100644 core/src/main/kotlin/com/mythlane/async/dispatchers/HytaleDispatchers.kt create mode 100644 core/src/main/kotlin/com/mythlane/async/dispatchers/WorldExecutor.kt create mode 100644 core/src/main/kotlin/com/mythlane/async/exception/HytaleAsyncException.kt create mode 100644 core/src/main/kotlin/com/mythlane/async/scope/PlayerScope.kt create mode 100644 core/src/main/kotlin/com/mythlane/async/scope/PluginScopes.kt create mode 100644 core/src/main/kotlin/com/mythlane/async/scope/WorldScopes.kt create mode 100644 core/src/test/kotlin/com/mythlane/async/ScopesTest.kt create mode 100644 core/src/test/kotlin/com/mythlane/async/SmokeTest.kt diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..2de5832 --- /dev/null +++ b/core/README.md @@ -0,0 +1,52 @@ +# `:core` + +Dispatchers and coroutine scopes. No knowledge of the Hytale SDK — the only +runtime deps are `kotlinx-coroutines` and SLF4J. + +## Dispatchers + +```kotlin +AsyncDispatchers.World(world) // confines to a specific world's main thread +AsyncDispatchers.HytaleIO // bounded pool for blocking I/O +AsyncDispatchers.HytaleScheduled // backs delay() and withTimeout() + +AsyncDispatchers.configureIo(parallelism = 16) +AsyncDispatchers.configureScheduled(myExecutor) +``` + +`World` takes a `WorldExecutor` interface, not the Hytale `World` class +directly. The `:binding` module wires the real `World` in via +`World.asExecutor()`. Tests substitute single-thread executors keyed by UUID. + +## Scopes + +Three registries, all `SupervisorJob`-backed (a child failure doesn't kill +siblings) and all defaulting to `HytaleIO`: + +```kotlin +PlayerScopes.of(uuid) // get-or-create +PlayerScopes.cancel(uuid) // idempotent +PlayerScopes.cancelAll() +PlayerScopes.activeCount() + +WorldScopes.of(worldUuid) // same shape +PluginScopes.of(plugin) // identity-keyed (IdentityHashMap), not equality +``` + +`Async.shutdown()` cancels all three registries and is idempotent — call it +from your plugin's `shutdown()`. + +## Exceptions + +A sealed hierarchy under `AsyncException` so consumers can catch one type if +they want a uniform error path: + +- `WorldClosedException` — dispatching to a dead world +- `NoWorldInContextException` — `MainHere` invoked off-context +- `ComponentNotFoundException` — strict `read`/`modify` on a missing component +- `ComponentTypeNotRegisteredException` — DSL used on an unregistered class + +## Tests + +`SmokeTest` and `ScopesTest` run against stub `WorldExecutor` implementations +backed by `Executors.newSingleThreadExecutor`. No Hytale jar required. diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..4762cd0 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + `java-library` +} + +dependencies { + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.coroutines.jdk8) + api(libs.slf4j.api) + + compileOnly(libs.hytale.server) + + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.kotest.assertions) + testImplementation(libs.mockk) + testRuntimeOnly(libs.slf4j.simple) +} diff --git a/core/src/main/kotlin/com/mythlane/async/HytaleAsync.kt b/core/src/main/kotlin/com/mythlane/async/HytaleAsync.kt new file mode 100644 index 0000000..d0883e7 --- /dev/null +++ b/core/src/main/kotlin/com/mythlane/async/HytaleAsync.kt @@ -0,0 +1,24 @@ +package com.mythlane.async + +import com.mythlane.async.scope.PlayerScopes +import com.mythlane.async.scope.PluginScopes +import com.mythlane.async.scope.WorldScopes + +/** + * Library-wide entry point. Currently exposes a single shutdown helper that + * cancels every coroutine scope tracked by Async — call from your + * plugin's `shutdown()`. + * + * Disconnect / world-unload wireup lives in the `:hytale` binding module + * (`installAsync()`), not here, so `:core` stays free of Hytale imports. + * + * @ThreadSafe + */ +object Async { + /** Cancels all player, world, and plugin scopes. Idempotent. */ + fun shutdown() { + PlayerScopes.cancelAll() + WorldScopes.cancelAll() + PluginScopes.cancelAll() + } +} diff --git a/core/src/main/kotlin/com/mythlane/async/dispatchers/HytaleDispatchers.kt b/core/src/main/kotlin/com/mythlane/async/dispatchers/HytaleDispatchers.kt new file mode 100644 index 0000000..7803509 --- /dev/null +++ b/core/src/main/kotlin/com/mythlane/async/dispatchers/HytaleDispatchers.kt @@ -0,0 +1,92 @@ +package com.mythlane.async.dispatchers + +import com.mythlane.async.exception.WorldClosedException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext + +/** + * Coroutine dispatchers tailored for the Hytale threading model. + * + * - [World]: confines work to a specific world's main thread. + * - [HytaleIO]: bounded pool for blocking I/O (DB, HTTP, file). + * - [HytaleScheduled]: backs `delay()` and timed tasks. + * + * @ThreadSafe All members may be accessed from any thread. + */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +object AsyncDispatchers { + + private val worldDispatchers = ConcurrentHashMap() + + /** Default IO parallelism; override via [configureIo]. */ + private const val DEFAULT_IO_PARALLELISM_MULTIPLIER = 2 + + @Volatile + private var ioDispatcher: CoroutineDispatcher = Dispatchers.IO.limitedParallelism( + Runtime.getRuntime().availableProcessors() * DEFAULT_IO_PARALLELISM_MULTIPLIER + ) + + @Volatile + private var scheduled: ScheduledExecutorService = Executors.newScheduledThreadPool(2) { r -> + Thread(r, "Async-Scheduled-${schedulerCounter.incrementAndGet()}").apply { isDaemon = true } + } + private val schedulerCounter = AtomicInteger(0) + + /** + * Returns a dispatcher that confines coroutines to [world]'s main thread. + * + * Each dispatched task is wrapped to honor coroutine cancellation: if the + * job is cancelled before the world thread picks the task up, it is skipped. + * + * @throws WorldClosedException if [world] is no longer alive at dispatch time. + */ + fun World(world: WorldExecutor): CoroutineDispatcher = + worldDispatchers.computeIfAbsent(world.worldId) { WorldCoroutineDispatcher(world) } + + /** Bounded IO dispatcher for blocking work. */ + val HytaleIO: CoroutineDispatcher get() = ioDispatcher + + /** Scheduled dispatcher; backs `delay()` and `withTimeout`. */ + val HytaleScheduled: CoroutineDispatcher get() = scheduled.asCoroutineDispatcher() + + /** + * Override the IO pool. Call once during plugin init if defaults aren't right. + */ + fun configureIo(parallelism: Int) { + require(parallelism > 0) { "parallelism must be > 0" } + ioDispatcher = Dispatchers.IO.limitedParallelism(parallelism) + } + + /** + * Override the scheduled pool. Call once during plugin init. + * Production wiring should pass `HytaleServer.SCHEDULED_EXECUTOR`. + */ + fun configureScheduled(executor: ScheduledExecutorService) { + scheduled = executor + } + + /** Test hook: drop the dispatcher cached for [worldId]. */ + internal fun evictWorld(worldId: java.util.UUID) { + worldDispatchers.remove(worldId) + } + + private class WorldCoroutineDispatcher(private val world: WorldExecutor) : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + if (!world.isAlive()) throw WorldClosedException(world.worldId.toString()) + world.execute(Runnable { + // Skip if the coroutine was cancelled before the world thread woke up. + val job = context[kotlinx.coroutines.Job] + if (job != null && !job.isActive) return@Runnable + block.run() + }) + } + + override fun toString(): String = "Dispatchers.World(${world.worldId})" + } +} diff --git a/core/src/main/kotlin/com/mythlane/async/dispatchers/WorldExecutor.kt b/core/src/main/kotlin/com/mythlane/async/dispatchers/WorldExecutor.kt new file mode 100644 index 0000000..2256cd3 --- /dev/null +++ b/core/src/main/kotlin/com/mythlane/async/dispatchers/WorldExecutor.kt @@ -0,0 +1,20 @@ +package com.mythlane.async.dispatchers + +import java.util.UUID + +/** + * Minimal abstraction over the bits of `com.hypixel.hytale.api.world.World` that + * `Dispatchers.World` actually needs. Tests substitute a single-thread executor; + * production wires this to `world.execute(Runnable)` and `world.isAlive()`. + * + * Keeping this as a SAM-friendly interface (not directly the Hytale World class) + * means `:core` does NOT have a hard `compileOnly` dependency on the Hytale jar + * for its core primitives — easier testing, cleaner module boundary. + * + * @see com.mythlane.async.dispatchers.AsyncDispatchers.World + */ +interface WorldExecutor { + val worldId: UUID + fun isAlive(): Boolean + fun execute(task: Runnable) +} diff --git a/core/src/main/kotlin/com/mythlane/async/exception/HytaleAsyncException.kt b/core/src/main/kotlin/com/mythlane/async/exception/HytaleAsyncException.kt new file mode 100644 index 0000000..28b8f14 --- /dev/null +++ b/core/src/main/kotlin/com/mythlane/async/exception/HytaleAsyncException.kt @@ -0,0 +1,26 @@ +package com.mythlane.async.exception + +/** + * Sealed root of every exception thrown by the Async library. + * Consumers can catch this to handle any library-specific failure uniformly. + */ +sealed class AsyncException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) + +/** Thrown when dispatching to a world that is no longer alive. */ +class WorldClosedException(worldId: String) : + AsyncException("World $worldId is closed; cannot dispatch") + +/** Thrown by `Dispatchers.MainHere` when the current coroutine context has no associated world. */ +class NoWorldInContextException : + AsyncException("No world found in current coroutine context") + +/** Thrown when a strict component access cannot find the requested component on an entity ref. */ +class ComponentNotFoundException(typeName: String) : + AsyncException("Component $typeName not present on entity ref") + +/** Thrown when [com.mythlane.async.ecs.ComponentRegistry] has no mapping for the requested Kotlin class. */ +class ComponentTypeNotRegisteredException(typeName: String) : + AsyncException( + "No ComponentType registered for $typeName. " + + "Call ComponentRegistry.register<$typeName>(componentType) during plugin start()." + ) diff --git a/core/src/main/kotlin/com/mythlane/async/scope/PlayerScope.kt b/core/src/main/kotlin/com/mythlane/async/scope/PlayerScope.kt new file mode 100644 index 0000000..16bc740 --- /dev/null +++ b/core/src/main/kotlin/com/mythlane/async/scope/PlayerScope.kt @@ -0,0 +1,49 @@ +package com.mythlane.async.scope + +import com.mythlane.async.dispatchers.AsyncDispatchers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.slf4j.LoggerFactory +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Per-player [CoroutineScope] registry. Scopes are created lazily on first lookup + * and cancelled atomically when [cancel] is invoked (typically from the + * `PlayerDisconnectEvent` listener wired by `Async.install`). + * + * Each scope uses a [SupervisorJob] so a single child failure does not cascade + * to siblings, and defaults to [AsyncDispatchers.HytaleIO]. + * + * @ThreadSafe + */ +object PlayerScopes { + private val log = LoggerFactory.getLogger(PlayerScopes::class.java) + private val scopes = ConcurrentHashMap() + + private val handler = CoroutineExceptionHandler { ctx, throwable -> + log.error("Unhandled exception in player scope ${ctx[kotlinx.coroutines.CoroutineName]?.name}", throwable) + } + + /** Get-or-create the scope for [playerId]. */ + fun of(playerId: UUID): CoroutineScope = + scopes.computeIfAbsent(playerId) { + CoroutineScope(SupervisorJob() + AsyncDispatchers.HytaleIO + handler) + } + + /** Cancel and remove the scope for [playerId]; safe no-op if absent. */ + fun cancel(playerId: UUID) { + scopes.remove(playerId)?.cancel() + } + + /** Cancel everything; intended for plugin shutdown. */ + fun cancelAll() { + val snapshot = scopes.values.toList() + scopes.clear() + snapshot.forEach { it.cancel() } + } + + internal fun activeCount(): Int = scopes.size +} diff --git a/core/src/main/kotlin/com/mythlane/async/scope/PluginScopes.kt b/core/src/main/kotlin/com/mythlane/async/scope/PluginScopes.kt new file mode 100644 index 0000000..5281c86 --- /dev/null +++ b/core/src/main/kotlin/com/mythlane/async/scope/PluginScopes.kt @@ -0,0 +1,54 @@ +package com.mythlane.async.scope + +import com.mythlane.async.dispatchers.AsyncDispatchers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.slf4j.LoggerFactory +import java.util.Collections +import java.util.IdentityHashMap + +/** + * Per-plugin [CoroutineScope] registry. Identity-keyed by the plugin instance — + * two plugins with equal `equals` (rare for plugins) still get distinct scopes, + * which matches the actual lifecycle ownership. + * + * Cancelled in the plugin's `shutdown()`, typically via [com.mythlane.async.Async.shutdown]. + * + * @ThreadSafe + */ +object PluginScopes { + private val log = LoggerFactory.getLogger(PluginScopes::class.java) + + // IdentityHashMap because plugins are reference-identity entities; sync wrapper + // since IdentityHashMap is not concurrent. + private val scopes: MutableMap = + Collections.synchronizedMap(IdentityHashMap()) + + private val handler = CoroutineExceptionHandler { _, throwable -> + log.error("Unhandled exception in plugin scope", throwable) + } + + fun of(plugin: Any): CoroutineScope = synchronized(scopes) { + scopes.getOrPut(plugin) { + CoroutineScope(SupervisorJob() + AsyncDispatchers.HytaleIO + handler) + } + } + + fun cancel(plugin: Any) { + val scope = synchronized(scopes) { scopes.remove(plugin) } + scope?.cancel() + } + + fun cancelAll() { + val snapshot = synchronized(scopes) { + val copy = scopes.values.toList() + scopes.clear() + copy + } + snapshot.forEach { it.cancel() } + } + + internal fun activeCount(): Int = synchronized(scopes) { scopes.size } +} diff --git a/core/src/main/kotlin/com/mythlane/async/scope/WorldScopes.kt b/core/src/main/kotlin/com/mythlane/async/scope/WorldScopes.kt new file mode 100644 index 0000000..51a86ce --- /dev/null +++ b/core/src/main/kotlin/com/mythlane/async/scope/WorldScopes.kt @@ -0,0 +1,45 @@ +package com.mythlane.async.scope + +import com.mythlane.async.dispatchers.AsyncDispatchers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.slf4j.LoggerFactory +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Per-world [CoroutineScope] registry, keyed by world UUID. Cancelled by the + * `:hytale` binding on world unload (or manually until the unload event ships). + * + * Scopes use [SupervisorJob] so a single child failure does not cascade, + * default to [AsyncDispatchers.HytaleIO], and log unhandled exceptions. + * + * @ThreadSafe + */ +object WorldScopes { + private val log = LoggerFactory.getLogger(WorldScopes::class.java) + private val scopes = ConcurrentHashMap() + + private val handler = CoroutineExceptionHandler { _, throwable -> + log.error("Unhandled exception in world scope", throwable) + } + + fun of(worldId: UUID): CoroutineScope = + scopes.computeIfAbsent(worldId) { + CoroutineScope(SupervisorJob() + AsyncDispatchers.HytaleIO + handler) + } + + fun cancel(worldId: UUID) { + scopes.remove(worldId)?.cancel() + } + + fun cancelAll() { + val snapshot = scopes.values.toList() + scopes.clear() + snapshot.forEach { it.cancel() } + } + + internal fun activeCount(): Int = scopes.size +} diff --git a/core/src/test/kotlin/com/mythlane/async/ScopesTest.kt b/core/src/test/kotlin/com/mythlane/async/ScopesTest.kt new file mode 100644 index 0000000..e39b88a --- /dev/null +++ b/core/src/test/kotlin/com/mythlane/async/ScopesTest.kt @@ -0,0 +1,83 @@ +package com.mythlane.async + +import com.mythlane.async.scope.PlayerScopes +import com.mythlane.async.scope.PluginScopes +import com.mythlane.async.scope.WorldScopes +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean + +class ScopesTest { + + @AfterEach fun tearDown() = Async.shutdown() + + @Test fun `WorldScopes - cancel stops in-flight work and removes scope`() = runTest { + val id = UUID.randomUUID() + val ran = AtomicBoolean(false) + val job = WorldScopes.of(id).launch { delay(10_000); ran.set(true) } + WorldScopes.cancel(id) + job.join() + ran.get() shouldBe false + WorldScopes.activeCount() shouldBe 0 + } + + @Test fun `WorldScopes - same UUID returns same scope`() { + val id = UUID.randomUUID() + val a = WorldScopes.of(id) + val b = WorldScopes.of(id) + (a === b) shouldBe true + } + + @Test fun `WorldScopes - distinct UUIDs are isolated on cancel`() = runTest { + val a = UUID.randomUUID(); val b = UUID.randomUUID() + val ranA = AtomicBoolean(false); val ranB = AtomicBoolean(false) + val jobA = WorldScopes.of(a).launch { delay(10_000); ranA.set(true) } + val jobB = WorldScopes.of(b).launch { delay(50); ranB.set(true) } + WorldScopes.cancel(a) + jobA.join(); jobB.join() + ranA.get() shouldBe false + ranB.get() shouldBe true + } + + @Test fun `PluginScopes - identity-keyed and cancel works`() = runTest { + val plugin = Any() + val ran = AtomicBoolean(false) + val job = PluginScopes.of(plugin).launch { delay(10_000); ran.set(true) } + PluginScopes.cancel(plugin) + job.join() + ran.get() shouldBe false + PluginScopes.activeCount() shouldBe 0 + } + + @Test fun `PluginScopes - two equal-but-distinct plugin instances get separate scopes`() { + // Identity, not equality. Use a value-equal data class instance pair. + data class P(val name: String) + val p1 = P("x"); val p2 = P("x") + (p1 == p2) shouldBe true + val s1 = PluginScopes.of(p1) + val s2 = PluginScopes.of(p2) + (s1 === s2) shouldBe false + } + + @Test fun `Async shutdown cancels all registries`() = runTest { + PlayerScopes.of(UUID.randomUUID()).launch { delay(10_000) } + WorldScopes.of(UUID.randomUUID()).launch { delay(10_000) } + PluginScopes.of(Any()).launch { delay(10_000) } + Async.shutdown() + PlayerScopes.activeCount() shouldBe 0 + WorldScopes.activeCount() shouldBe 0 + PluginScopes.activeCount() shouldBe 0 + } + + @Test fun `cancelAll is idempotent`() { + WorldScopes.cancelAll() + WorldScopes.cancelAll() + PluginScopes.cancelAll() + PluginScopes.cancelAll() + } +} diff --git a/core/src/test/kotlin/com/mythlane/async/SmokeTest.kt b/core/src/test/kotlin/com/mythlane/async/SmokeTest.kt new file mode 100644 index 0000000..c81cc55 --- /dev/null +++ b/core/src/test/kotlin/com/mythlane/async/SmokeTest.kt @@ -0,0 +1,77 @@ +package com.mythlane.async + +import com.mythlane.async.dispatchers.AsyncDispatchers +import com.mythlane.async.dispatchers.WorldExecutor +import com.mythlane.async.exception.WorldClosedException +import com.mythlane.async.scope.PlayerScopes +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * Smoke test: `Dispatchers.World` + `PlayerScopes` end-to-end against a stub world. + * Proves that work submitted from an arbitrary thread lands on the world's main thread, + * and that disconnecting a player cancels in-flight work. + */ +class SmokeTest { + + private class StubWorld(override val worldId: UUID = UUID.randomUUID()) : WorldExecutor { + private val exec = Executors.newSingleThreadExecutor { r -> + Thread(r, "Stub-World-$worldId").also { mainThread.set(it) } + } + val mainThread = AtomicReference() + private val alive = AtomicBoolean(true) + override fun isAlive() = alive.get() + override fun execute(task: Runnable) { exec.submit(task) } + fun shutdown() { alive.set(false); exec.shutdownNow(); exec.awaitTermination(2, TimeUnit.SECONDS) } + } + + private val world = StubWorld() + + @AfterEach fun tearDown() { + AsyncDispatchers.evictWorld(world.worldId) + PlayerScopes.cancelAll() + world.shutdown() + } + + @Test fun `World dispatcher confines work to world main thread`() = runTest { + val landed = AtomicReference() + withContext(AsyncDispatchers.World(world)) { + landed.set(Thread.currentThread()) + } + landed.get() shouldBe world.mainThread.get() + } + + @Test fun `dispatching to a dead world throws WorldClosedException`() = runTest { + world.shutdown() + shouldThrow { + withContext(AsyncDispatchers.World(world)) { /* never runs */ } + } + } + + @Test fun `cancelling player scope stops in-flight work`() = runTest { + val playerId = UUID.randomUUID() + val scope = PlayerScopes.of(playerId) + val ran = AtomicBoolean(false) + + val job = scope.launch { + delay(10_000) + ran.set(true) + } + PlayerScopes.cancel(playerId) + job.join() + + ran.get() shouldBe false + PlayerScopes.activeCount() shouldBe 0 + } +}