Files
kayjaydee 1870aeea12 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.
2026-04-28 16:30:02 +02:00
..
2026-04-28 16:30:02 +02:00
2026-04-28 16:30:02 +02:00

: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:

// 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

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:

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.