feat(ecs): suspending component DSL

read / readOrNull / modify primitives that take an EntityHandle,
switch to the entity's world dispatcher, run the block on the world
thread, and return to the caller's dispatcher. ComponentRegistry
maps KClass to opaque ComponentType keys for O(1) hot-path lookup.
This commit is contained in:
2026-04-28 16:30:02 +02:00
parent a10294c01f
commit 1870aeea12
6 changed files with 361 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
# `:ecs`
The component DSL. Suspending functions that take an `EntityHandle`, switch
to the entity's world thread, and return cleanly. No Hytale imports.
## Setup
Once per component type, at plugin start:
```kotlin
// Register each component type once, with whatever ComponentType<EntityStore, T>
// your EntityStore.REGISTRY.register(...) call returned at SDK setup time.
ComponentRegistry.register<PlayerStats>(yourPlayerStatsComponentType)
```
The registry is a `ConcurrentHashMap<KClass<*>, Any>` — lookup on the hot
path is one read, no reflection. The opaque `Any` key is whatever Hytale's
`ComponentType<EntityStore, T>` resolves to at runtime.
## DSL
```kotlin
read<T, R>(entity) { } // strict — throws ComponentNotFoundException if missing
readOrNull<T, R>(entity) { } // returns null instead
modify<T>(entity) { } // mutate, no return
modify<T, R>(entity) { } // mutate, return a value (Boolean for "did it work?", etc.)
```
Every entry switches dispatcher to `AsyncDispatchers.World(entity.world)`,
runs the block on the world thread, suspends until done, returns to the
caller's dispatcher.
## Mutate-in-place
Hytale's `Store` exposes no public `setComponent(ref, value)`. The component
returned by `getComponent` is the live in-store instance — mutating it is
the persistence step. There's no copy and no rollback. If a `modify` block
throws after a partial mutation, the partial state stays.
The practical rule: validate first, mutate second. Don't treat `modify` as a
transaction; treat it as "best-effort atomic chunk on the world thread".
## EntityHandle
The DSL takes an `EntityHandle` interface — single method, returns the live
component for an opaque type key:
```kotlin
interface EntityHandle {
val world: WorldExecutor
fun <T : Any> get(typeKey: Any): T?
}
```
Production wiring lives in `:binding` (`PlayerRef.toEntityHandle()`). Tests
use a one-line `MapEntity` backed by a `ConcurrentHashMap`.
## Tests
`ComponentDslTest` covers the strict/lenient variants, the mutate-and-persist
contract, and the registration error paths. Runs against an in-memory stub
entity — no Hytale dep.
+16
View File
@@ -0,0 +1,16 @@
plugins {
alias(libs.plugins.kotlin.jvm)
`java-library`
}
dependencies {
api(project(":core"))
compileOnly(libs.hytale.server)
testImplementation(project(":core"))
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.kotest.assertions)
testImplementation(libs.mockk)
}
@@ -0,0 +1,89 @@
package com.mythlane.async.ecs
import com.mythlane.async.dispatchers.AsyncDispatchers
import com.mythlane.async.exception.ComponentNotFoundException
import kotlinx.coroutines.withContext
/**
* Suspending component DSL.
*
* Every entry point switches to the entity's world dispatcher, runs the user
* block on the world thread, and suspends until done. The block receives the
* **live** component; any mutation is persisted in place — there is no
* write-back step and no copy-on-read.
*
* **No rollback.** If a [modify] block throws after a partial mutation, the
* mutation stays. This mirrors Hytale ECS semantics; treat blocks as best-effort
* atomic chunks of work and prefer pure validation before mutation.
*
* **Cancellation.** If the coroutine is cancelled before the world thread picks
* up the task, the block never runs. Once running, blocks complete — they are
* not interrupted mid-mutation.
*/
/**
* Read a projection from a component without (intentionally) mutating it.
*
* @throws ComponentNotFoundException if the component is missing on [entity].
* @throws com.mythlane.async.exception.ComponentTypeNotRegisteredException
* if [T] was never passed to [ComponentRegistry.register].
*/
suspend inline fun <reified T : Any, R> read(
entity: EntityHandle,
crossinline block: T.() -> R,
): R {
val key = ComponentRegistry.keyOf(T::class)
val name = T::class.simpleName ?: T::class.toString()
return withContext(AsyncDispatchers.World(entity.world)) {
val comp = entity.get<T>(key) ?: throw ComponentNotFoundException(name)
comp.block()
}
}
/**
* Like [read], but returns null if the component is absent or the type is unregistered.
*/
suspend inline fun <reified T : Any, R : Any> readOrNull(
entity: EntityHandle,
crossinline block: T.() -> R,
): R? {
val key = ComponentRegistry.keyOrNull(T::class) ?: return null
return withContext(AsyncDispatchers.World(entity.world)) {
entity.get<T>(key)?.block()
}
}
/**
* Mutate the live component on the world thread. The block receives the
* in-store instance — mutations persist without an explicit commit.
*
* Returning a value from the block is supported: typically `Unit` for plain
* mutations, or a `Boolean`/data type when you need to signal an outcome
* to the caller (e.g. `if (xp >= 100) { xp = 0; level += 1; true } else false`).
*
* @throws ComponentNotFoundException if the component is missing on [entity].
*/
suspend inline fun <reified T : Any, R> modify(
entity: EntityHandle,
crossinline block: T.() -> R,
): R {
val key = ComponentRegistry.keyOf(T::class)
val name = T::class.simpleName ?: T::class.toString()
return withContext(AsyncDispatchers.World(entity.world)) {
val comp = entity.get<T>(key) ?: throw ComponentNotFoundException(name)
comp.block()
}
}
/**
* `Unit`-returning convenience overload so callers can write
* `modify<PlayerStats>(handle) { level += 1 }` without specifying the return type.
* Kotlin can't infer one of two `reified` type args, hence the dedicated overload.
*/
@JvmName("modifyUnit")
suspend inline fun <reified T : Any> modify(
entity: EntityHandle,
crossinline block: T.() -> Unit,
) {
modify<T, Unit>(entity, block)
}
@@ -0,0 +1,46 @@
package com.mythlane.async.ecs
import com.mythlane.async.exception.ComponentTypeNotRegisteredException
import java.util.concurrent.ConcurrentHashMap
import kotlin.reflect.KClass
/**
* Maps a Kotlin component class to its Hytale `ComponentType` token (opaque [Any]).
*
* Lookup is a single [ConcurrentHashMap] read on the hot path — no reflection.
* Register every component type used with the DSL once during plugin start():
*
* ```
* override fun start() {
* ComponentRegistry.register<PlayerStats>(PlayerStats.getComponentType())
* }
* ```
*
* @ThreadSafe
*/
object ComponentRegistry {
private val map = ConcurrentHashMap<KClass<*>, Any>()
/** Register [componentType] (a Hytale `ComponentType<T>` instance) for [T]. */
inline fun <reified T : Any> register(componentType: Any) {
registerClass(T::class, componentType)
}
/** Non-inline overload usable from Java. */
@JvmStatic
fun registerClass(klass: KClass<*>, componentType: Any) {
map[klass] = componentType
}
/** Throws [ComponentTypeNotRegisteredException] if [klass] has no mapping. */
@JvmStatic
fun keyOf(klass: KClass<*>): Any =
map[klass] ?: throw ComponentTypeNotRegisteredException(klass.qualifiedName ?: klass.toString())
/** Returns the mapping for [klass] or null. Mirrors the `OrNull` DSL flavor. */
@JvmStatic
fun keyOrNull(klass: KClass<*>): Any? = map[klass]
/** Test/reset hook. */
fun clear() { map.clear() }
}
@@ -0,0 +1,30 @@
package com.mythlane.async.ecs
import com.mythlane.async.dispatchers.WorldExecutor
/**
* Thin abstraction over `Ref<EntityStore>` + its owning `Store<EntityStore>`.
*
* Hytale's ECS exposes no public `Store.setComponent(ref, value)`. Components
* returned by `Store.getComponent` are the **live** in-store instances; mutating
* one is the persist operation. There is no copy-on-read, and no rollback if
* the mutation throws after a partial write.
*
* That single-method shape is therefore deliberate: read the live component,
* mutate it on the world thread, done.
*
* Production wiring: a one-liner adapter in `:hytale` builds an instance from a
* `Ref<EntityStore>` and delegates [get] to `store.getComponent(ref, typeKey as ComponentType)`,
* resolving [world] via `store.getWorld()` (or whichever accessor ships).
*
* Tests substitute a `Map<Any, Any>` keyed by opaque component-type tokens.
*
* @WorldThreadOnly [get]
* @AnyThread [world]
*/
interface EntityHandle {
val world: WorldExecutor
/** Returns the live component for [typeKey] or null if absent. */
fun <T : Any> get(typeKey: Any): T?
}
@@ -0,0 +1,117 @@
package com.mythlane.async.ecs
import com.mythlane.async.dispatchers.WorldExecutor
import com.mythlane.async.exception.ComponentNotFoundException
import com.mythlane.async.exception.ComponentTypeNotRegisteredException
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
class ComponentDslTest {
data class PlayerStats(var level: Int = 1, var xp: Int = 0)
private val statsKey: Any = "stats-token" // opaque, mimics Hytale ComponentType<PlayerStats>
private class StubWorld : WorldExecutor {
override val worldId: UUID = UUID.randomUUID()
val mainThread = AtomicReference<Thread>()
private val exec = Executors.newSingleThreadExecutor { r ->
Thread(r, "Stub-World").also { mainThread.set(it) }
}
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 class MapEntity(override val world: WorldExecutor) : EntityHandle {
val store = ConcurrentHashMap<Any, Any>()
@Suppress("UNCHECKED_CAST")
override fun <T : Any> get(typeKey: Any): T? = store[typeKey] as T?
}
private val world = StubWorld()
private lateinit var entity: MapEntity
@BeforeEach fun setUp() {
ComponentRegistry.register<PlayerStats>(statsKey)
entity = MapEntity(world)
}
@AfterEach fun tearDown() {
ComponentRegistry.clear()
world.shutdown()
}
@Test fun `read runs on the world thread and returns projection`() = runTest {
entity.store[statsKey] = PlayerStats(level = 7)
val landed = AtomicReference<Thread>()
val level = read<PlayerStats, Int>(entity) {
landed.set(Thread.currentThread())
level
}
level shouldBe 7
landed.get() shouldBe world.mainThread.get()
}
@Test fun `modify Unit overload mutates without explicit return type`() = runTest {
entity.store[statsKey] = PlayerStats(level = 5, xp = 0)
modify<PlayerStats>(entity) { level = 7 }
(entity.store[statsKey] as PlayerStats).level shouldBe 7
}
@Test fun `modify mutates and writes back`() = runTest {
entity.store[statsKey] = PlayerStats(level = 1, xp = 50)
modify<PlayerStats, Unit>(entity) { level += 1; xp = 0 }
val after = entity.store[statsKey] as PlayerStats
after.level shouldBe 2
after.xp shouldBe 0
}
@Test fun `read throws when component absent`() = runTest {
shouldThrow<ComponentNotFoundException> {
read<PlayerStats, Int>(entity) { level }
}
}
@Test fun `readOrNull returns null when component absent`() = runTest {
readOrNull<PlayerStats, Int>(entity) { level } shouldBe null
}
@Test fun `readOrNull returns null when type unregistered`() = runTest {
ComponentRegistry.clear()
readOrNull<PlayerStats, Int>(entity) { level } shouldBe null
}
@Test fun `read throws when type unregistered`() = runTest {
ComponentRegistry.clear()
shouldThrow<ComponentTypeNotRegisteredException> {
read<PlayerStats, Int>(entity) { level }
}
}
@Test fun `modify with Boolean return signals success and persists in place`() = runTest {
entity.store[statsKey] = PlayerStats(level = 1, xp = 100)
val leveled = modify<PlayerStats, Boolean>(entity) {
if (xp >= 100) { xp = 0; level += 1; true } else false
}
leveled shouldBe true
(entity.store[statsKey] as PlayerStats).level shouldBe 2
val again = modify<PlayerStats, Boolean>(entity) {
if (xp >= 100) { xp = 0; level += 1; true } else false
}
again shouldBe false
(entity.store[statsKey] as PlayerStats).level shouldBe 2
}
}