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:
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user