diff --git a/ecs/README.md b/ecs/README.md new file mode 100644 index 0000000..64f57a5 --- /dev/null +++ b/ecs/README.md @@ -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 +// your EntityStore.REGISTRY.register(...) call returned at SDK setup time. +ComponentRegistry.register(yourPlayerStatsComponentType) +``` + +The registry is a `ConcurrentHashMap, Any>` — lookup on the hot +path is one read, no reflection. The opaque `Any` key is whatever Hytale's +`ComponentType` resolves to at runtime. + +## DSL + +```kotlin +read(entity) { … } // strict — throws ComponentNotFoundException if missing +readOrNull(entity) { … } // returns null instead + +modify(entity) { … } // mutate, no return +modify(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 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. diff --git a/ecs/build.gradle.kts b/ecs/build.gradle.kts new file mode 100644 index 0000000..13c34d1 --- /dev/null +++ b/ecs/build.gradle.kts @@ -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) +} diff --git a/ecs/src/main/kotlin/com/mythlane/async/ecs/ComponentDsl.kt b/ecs/src/main/kotlin/com/mythlane/async/ecs/ComponentDsl.kt new file mode 100644 index 0000000..eccf014 --- /dev/null +++ b/ecs/src/main/kotlin/com/mythlane/async/ecs/ComponentDsl.kt @@ -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 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(key) ?: throw ComponentNotFoundException(name) + comp.block() + } +} + +/** + * Like [read], but returns null if the component is absent or the type is unregistered. + */ +suspend inline fun readOrNull( + entity: EntityHandle, + crossinline block: T.() -> R, +): R? { + val key = ComponentRegistry.keyOrNull(T::class) ?: return null + return withContext(AsyncDispatchers.World(entity.world)) { + entity.get(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 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(key) ?: throw ComponentNotFoundException(name) + comp.block() + } +} + +/** + * `Unit`-returning convenience overload so callers can write + * `modify(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 modify( + entity: EntityHandle, + crossinline block: T.() -> Unit, +) { + modify(entity, block) +} diff --git a/ecs/src/main/kotlin/com/mythlane/async/ecs/ComponentRegistry.kt b/ecs/src/main/kotlin/com/mythlane/async/ecs/ComponentRegistry.kt new file mode 100644 index 0000000..a7527c8 --- /dev/null +++ b/ecs/src/main/kotlin/com/mythlane/async/ecs/ComponentRegistry.kt @@ -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.getComponentType()) + * } + * ``` + * + * @ThreadSafe + */ +object ComponentRegistry { + private val map = ConcurrentHashMap, Any>() + + /** Register [componentType] (a Hytale `ComponentType` instance) for [T]. */ + inline fun 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() } +} diff --git a/ecs/src/main/kotlin/com/mythlane/async/ecs/EntityHandle.kt b/ecs/src/main/kotlin/com/mythlane/async/ecs/EntityHandle.kt new file mode 100644 index 0000000..d5c6d33 --- /dev/null +++ b/ecs/src/main/kotlin/com/mythlane/async/ecs/EntityHandle.kt @@ -0,0 +1,30 @@ +package com.mythlane.async.ecs + +import com.mythlane.async.dispatchers.WorldExecutor + +/** + * Thin abstraction over `Ref` + its owning `Store`. + * + * 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` and delegates [get] to `store.getComponent(ref, typeKey as ComponentType)`, + * resolving [world] via `store.getWorld()` (or whichever accessor ships). + * + * Tests substitute a `Map` 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 get(typeKey: Any): T? +} diff --git a/ecs/src/test/kotlin/com/mythlane/async/ecs/ComponentDslTest.kt b/ecs/src/test/kotlin/com/mythlane/async/ecs/ComponentDslTest.kt new file mode 100644 index 0000000..fe28177 --- /dev/null +++ b/ecs/src/test/kotlin/com/mythlane/async/ecs/ComponentDslTest.kt @@ -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 + + private class StubWorld : WorldExecutor { + override val worldId: UUID = UUID.randomUUID() + val mainThread = AtomicReference() + 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() + @Suppress("UNCHECKED_CAST") + override fun get(typeKey: Any): T? = store[typeKey] as T? + } + + private val world = StubWorld() + private lateinit var entity: MapEntity + + @BeforeEach fun setUp() { + ComponentRegistry.register(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() + val level = read(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(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(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 { + read(entity) { level } + } + } + + @Test fun `readOrNull returns null when component absent`() = runTest { + readOrNull(entity) { level } shouldBe null + } + + @Test fun `readOrNull returns null when type unregistered`() = runTest { + ComponentRegistry.clear() + readOrNull(entity) { level } shouldBe null + } + + @Test fun `read throws when type unregistered`() = runTest { + ComponentRegistry.clear() + shouldThrow { + read(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(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(entity) { + if (xp >= 100) { xp = 0; level += 1; true } else false + } + again shouldBe false + (entity.store[statsKey] as PlayerStats).level shouldBe 2 + } +}