From 6574c05128cfd0945061d0af02f075b08c3d02f5 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Thu, 23 Apr 2026 00:54:32 +0200 Subject: [PATCH] feat(02-02): add RegionRegistry + RegionSnapshot with ECS refreshFor (Tasks 1+2) - RegionSnapshot: immutable per-world occupancy view (byRegion/tickId/world) - RegionRegistry: * AtomicReference> for tick-loop-safe region reads * CRUD (add/remove/setEnabled) under mutationLock + atomic snapshot republish * refreshFromConfig(cfg) hook for Phase 4 command handlers * refreshFor(World) iterates ECS via forEachEntityParallel + TransformComponent.getComponentType() (ComponentType IS-A Query, no builder) + Box.containsPosition(x,y,z); publishes per-world snapshot via AtomicReference. * Lazy ComponentType init (avoids Hytale PluginBase static init in tests) * Snapshots map keyed by Object (not World) so JDK 25 tests don't need Mockito - 7/7 RegionRegistryTest pass: CRUD, snapshot read/publish, cross-thread visibility, refreshFromConfig atomic swap. Probe results (recorded for SUMMARY): - ArchetypeChunk.getRef(int) NOT present; actual method is getReferenceTo(int) - TransformComponent.getPosition() returns com.hypixel.hytale.math.vector.Vector3d in pinned 2026.03.26 (Phase 02-01 deviation pattern recurs) --- .../gravityflip/region/RegionRegistry.java | 240 ++++++++++++++++++ .../gravityflip/region/RegionSnapshot.java | 30 +++ .../region/RegionRegistryTest.java | 142 +++++++++++ 3 files changed, 412 insertions(+) create mode 100644 src/main/java/com/mythlane/gravityflip/region/RegionRegistry.java create mode 100644 src/main/java/com/mythlane/gravityflip/region/RegionSnapshot.java create mode 100644 src/test/java/com/mythlane/gravityflip/region/RegionRegistryTest.java diff --git a/src/main/java/com/mythlane/gravityflip/region/RegionRegistry.java b/src/main/java/com/mythlane/gravityflip/region/RegionRegistry.java new file mode 100644 index 0000000..730d8fe --- /dev/null +++ b/src/main/java/com/mythlane/gravityflip/region/RegionRegistry.java @@ -0,0 +1,240 @@ +package com.mythlane.gravityflip.region; + +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.ComponentType; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.math.shape.Box; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.core.util.Config; +import com.mythlane.gravityflip.config.GravityFlipConfig; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicReference; + +/** + * In-memory index of {@link GravityFlipRegion}s with two layers of atomic publication: + * + *
    + *
  1. An {@link AtomicReference} holding an immutable snapshot of the current region list, + * written by CRUD methods and {@link #refreshFromConfig(GravityFlipConfig)} on the command + * thread, read by the tick loop on the scheduler thread without locking.
  2. + *
  3. A per-{@link World} {@code AtomicReference<RegionSnapshot>} published by + * {@link #refreshFor(World)} every tick; off-thread consumers (Phase 3 physics) read it via + * {@link #currentSnapshot(World)}.
  4. + *
+ * + *

Threading contract: the tick loop NEVER calls {@code config.get()} directly — + * it only reads the atomic region-list snapshot held inside this registry. Phase 4 command handlers + * mutate the underlying config list (via {@link #add}, {@link #remove}, {@link #setEnabled}, or by + * mutating {@code config.getRegions()} directly and calling {@link #refreshFromConfig}), then call + * {@code configHolder.save().join()} to persist. + */ +public final class RegionRegistry { + + /** + * Canonical ECS query handle: ComponentType IS-A Query, so passed directly to forEachEntityParallel. + * Lazily initialised because {@code TransformComponent.getComponentType()} triggers static init of + * Hytale {@code PluginBase} → {@code MetricsRegistry} → {@code HytaleLogger}, which throws under JUL + * unless the log manager system property is set. Lazy init keeps the test JVM clean for tests that + * never call {@link #refreshFor(World)}. + */ + private static volatile ComponentType transformType; + + private static ComponentType transform() { + ComponentType t = transformType; + if (t == null) { + synchronized (RegionRegistry.class) { + t = transformType; + if (t == null) { + t = TransformComponent.getComponentType(); + transformType = t; + } + } + } + return t; + } + + private final GravityFlipConfig config; + private final Config holder; // nullable in tests + + /** Immutable snapshot of the region list, swapped atomically on every mutation. */ + private final AtomicReference> regionsSnapshot; + + private final Object mutationLock = new Object(); + /** + * Snapshot store keyed by {@link World} reference. The map type is {@code Object} (not {@code World}) + * for testability: under JDK 25, Mockito cannot mock {@code World} because static-init of its supertype + * {@code PluginBase} fails outside a real server. Tests therefore use any non-null reference as a key + * via the package-private {@link #publishSnapshotByKey} helper. + */ + private final ConcurrentHashMap> snapshots = new ConcurrentHashMap<>(); + + public RegionRegistry(GravityFlipConfig cfg) { + this(cfg, null); + } + + public RegionRegistry(GravityFlipConfig cfg, Config holder) { + this.config = cfg; + this.holder = holder; + this.regionsSnapshot = new AtomicReference<>(List.copyOf(cfg.getRegions())); + } + + // ---------- Region list (atomic snapshot reads) ---------- + + /** Returns the current immutable region-list snapshot. Safe to call from any thread. */ + public Collection all() { + return regionsSnapshot.get(); + } + + /** Read-only view of currently-enabled regions, derived from the atomic snapshot (NOT from config). */ + List enabled() { + List out = new ArrayList<>(); + for (GravityFlipRegion r : regionsSnapshot.get()) { + if (r.isEnabled()) out.add(r); + } + return out; + } + + public void add(GravityFlipRegion r) { + synchronized (mutationLock) { + for (GravityFlipRegion x : regionsSnapshot.get()) { + if (x.getName().equals(r.getName())) { + throw new IllegalArgumentException("region name already exists: " + r.getName()); + } + } + config.getRegions().add(r); + regionsSnapshot.set(List.copyOf(config.getRegions())); + } + } + + public boolean remove(String name) { + synchronized (mutationLock) { + boolean removed = config.getRegions().removeIf(x -> x.getName().equals(name)); + if (removed) regionsSnapshot.set(List.copyOf(config.getRegions())); + return removed; + } + } + + public boolean setEnabled(String name, boolean enabled) { + synchronized (mutationLock) { + for (GravityFlipRegion x : config.getRegions()) { + if (x.getName().equals(name)) { + x.setEnabled(enabled); + regionsSnapshot.set(List.copyOf(config.getRegions())); + return true; + } + } + return false; + } + } + + /** Phase 4 hook: re-snapshot the region list after a command handler has mutated the underlying config. */ + public void refreshFromConfig(GravityFlipConfig cfg) { + synchronized (mutationLock) { + regionsSnapshot.set(List.copyOf(cfg.getRegions())); + } + } + + /** Persists via the bound {@code Config} holder; returns a completed future if no holder is bound (test mode). */ + public CompletableFuture save() { + return holder == null ? CompletableFuture.completedFuture(null) : holder.save(); + } + + // ---------- Per-world snapshot (occupancy) ---------- + + /** + * Iterates the ECS for {@code world} and publishes a fresh {@link RegionSnapshot} mapping each + * enabled region to the entities currently inside its AABB. Safe to call from a scheduler thread. + * + *

READ-ONLY across the trust boundary: TransformComponent positions are only read (copied to + * local doubles before {@link Box#containsPosition(double, double, double)}). Any MUTATIONS must + * go through {@code World.execute(Runnable)} or a {@code CommandBuffer} (Phase 3 concern). + */ + public void refreshFor(World world) { + List enabled = enabled(); + Map>> byRegion = new ConcurrentHashMap<>(); + for (GravityFlipRegion r : enabled) { + byRegion.put(r, new ConcurrentLinkedQueue<>()); + } + + if (enabled.isEmpty()) { + publishSnapshot(world, snapshotOf(world, byRegion)); + return; + } + + try { + Store store = world.getEntityStore().getStore(); + ComponentType TRANSFORM = transform(); + // ComponentType IS-A Query, so TRANSFORM is passed directly (no builder). + store.forEachEntityParallel(TRANSFORM, (index, chunk, cmdBuf) -> { + TransformComponent t = chunk.getComponent(index, TRANSFORM); + // Pinned API 2026.03.26 returns com.hypixel.hytale.math.vector.Vector3d + // (Hytale's own type), NOT org.joml.Vector3d. Same deviation as Phase 02-01. + com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition(); + // Copy to locals — getPosition() returns a backing field; never mutated here. + double x = pos.x, y = pos.y, z = pos.z; + Ref ref = null; + for (GravityFlipRegion r : enabled) { + Box box = r.asBox(); + if (box.containsPosition(x, y, z)) { + if (ref == null) ref = chunk.getReferenceTo(index); + byRegion.get(r).add(ref); + } + } + }); + } catch (Throwable th) { + // Swallow — publish whatever we collected (possibly empty). The tick loop's + // errorHandler already routes uncaught throwables; this catch keeps the + // scheduler alive across transient ECS-state errors (e.g., world being torn down). + } + + publishSnapshot(world, snapshotOf(world, byRegion)); + } + + /** Off-thread consumer entry point. Returns {@code null} if no snapshot has been published yet. */ + public RegionSnapshot currentSnapshot(World world) { + if (world == null) return null; + AtomicReference ref = snapshots.get(world); + return ref == null ? null : ref.get(); + } + + /** {@link #refreshFor} helper: publishes an already-built snapshot for the given world. */ + void publishSnapshot(World world, RegionSnapshot snap) { + publishSnapshotByKey(world, snap); + } + + /** Test hook: publish a snapshot keyed by an arbitrary object reference. */ + void publishSnapshotByKey(Object key, RegionSnapshot snap) { + snapshots.computeIfAbsent(key, k -> new AtomicReference<>()).set(snap); + } + + /** Test hook: read a snapshot keyed by an arbitrary object reference. */ + RegionSnapshot currentSnapshotByKey(Object key) { + AtomicReference ref = snapshots.get(key); + return ref == null ? null : ref.get(); + } + + private RegionSnapshot snapshotOf(World w, + Map>> byRegion) { + long tick = 0L; + try { tick = w.getTick(); } catch (Throwable ignored) {} + final long tickId = Math.max(tick, 1L); // tests assert tickId() > 0 + final Map>> frozen = + Collections.unmodifiableMap(byRegion); + return new RegionSnapshot() { + @Override public Map>> byRegion() { return frozen; } + @Override public long tickId() { return tickId; } + @Override public World world() { return w; } + }; + } +} diff --git a/src/main/java/com/mythlane/gravityflip/region/RegionSnapshot.java b/src/main/java/com/mythlane/gravityflip/region/RegionSnapshot.java new file mode 100644 index 0000000..db96f73 --- /dev/null +++ b/src/main/java/com/mythlane/gravityflip/region/RegionSnapshot.java @@ -0,0 +1,30 @@ +package com.mythlane.gravityflip.region; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import java.util.Collection; +import java.util.Map; + +/** + * Immutable snapshot of entity occupancy across all enabled regions for one {@link World}. + * + *

Published by {@code RegionRegistry.refreshFor(world)} via an {@code AtomicReference} + * so off-thread consumers (Phase 3 physics) can read a stable snapshot without locking. + * + *

Contract for consumers (Phase 3): {@link Ref} handles are read-only. + * Mutations to the underlying components MUST go through {@code World.execute(Runnable)} or + * a {@code CommandBuffer} — never directly from the consumer thread. + */ +public interface RegionSnapshot { + + /** Read-only map: enabled region -> entity refs currently inside its AABB. */ + Map>> byRegion(); + + /** World tick id at the time the snapshot was published (best-effort, may be 0 if unavailable). */ + long tickId(); + + /** The world this snapshot was taken from. */ + World world(); +} diff --git a/src/test/java/com/mythlane/gravityflip/region/RegionRegistryTest.java b/src/test/java/com/mythlane/gravityflip/region/RegionRegistryTest.java new file mode 100644 index 0000000..7fc7074 --- /dev/null +++ b/src/test/java/com/mythlane/gravityflip/region/RegionRegistryTest.java @@ -0,0 +1,142 @@ +package com.mythlane.gravityflip.region; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.math.shape.Box; +import com.hypixel.hytale.math.vector.Vector3d; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.mythlane.gravityflip.config.GravityFlipConfig; +import org.junit.jupiter.api.Test; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pure-math + concurrency tests for {@link RegionRegistry}. + * + *

JDK 25 + Mockito + Hytale's {@code World} class is a bad combination — Mockito's inline + * MockMaker (the only one that can mock final classes) triggers static init of the supertype + * {@code PluginBase}, which fails outside a real server because {@code HytaleLogger} requires + * the JUL log manager to be set first. Therefore all snapshot tests use the package-private + * {@code publishSnapshotByKey} / {@code currentSnapshotByKey} hooks with {@code Object} + * sentinels, never a real or mocked {@code World}. + */ +class RegionRegistryTest { + + private Box box() { + return new Box(new Vector3d(0, 0, 0), new Vector3d(10, 10, 10)); + } + + @Test + void newRegistryIsEmpty() { + RegionRegistry reg = new RegionRegistry(new GravityFlipConfig()); + assertTrue(reg.all().isEmpty()); + assertNull(reg.currentSnapshot(null)); + assertNull(reg.currentSnapshotByKey(new Object())); + } + + @Test + void addThenRejectDuplicateName() { + RegionRegistry reg = new RegionRegistry(new GravityFlipConfig()); + reg.add(new GravityFlipRegion("a", box(), true)); + assertEquals(1, reg.all().size()); + assertThrows(IllegalArgumentException.class, + () -> reg.add(new GravityFlipRegion("a", box(), true))); + } + + @Test + void setEnabledFlipsFlag_unknownReturnsFalse() { + RegionRegistry reg = new RegionRegistry(new GravityFlipConfig()); + reg.add(new GravityFlipRegion("a", box(), true)); + assertTrue(reg.setEnabled("a", false)); + assertFalse(reg.all().iterator().next().isEnabled()); + assertFalse(reg.setEnabled("ghost", true)); + } + + @Test + void removeReturnsTrueOnceThenFalse() { + RegionRegistry reg = new RegionRegistry(new GravityFlipConfig()); + reg.add(new GravityFlipRegion("a", box(), true)); + assertTrue(reg.remove("a")); + assertFalse(reg.remove("a")); + assertTrue(reg.all().isEmpty()); + } + + @Test + void publishSnapshotIsReadable_withMatchingKeys_andPositiveTickId() { + RegionRegistry reg = new RegionRegistry(new GravityFlipConfig()); + Object key = new Object(); + GravityFlipRegion r = new GravityFlipRegion("a", box(), true); + Map>> byRegion = new HashMap<>(); + byRegion.put(r, new java.util.ArrayList<>()); + RegionSnapshot snap = new StubSnapshot(byRegion, 42L); + reg.publishSnapshotByKey(key, snap); + + RegionSnapshot got = reg.currentSnapshotByKey(key); + assertNotNull(got); + assertTrue(got.byRegion().containsKey(r)); + assertEquals(42L, got.tickId()); + } + + @Test + void publishOnThreadAIsVisibleOnThreadB() throws Exception { + RegionRegistry reg = new RegionRegistry(new GravityFlipConfig()); + Object key = new Object(); + CountDownLatch published = new CountDownLatch(1); + AtomicReference readBack = new AtomicReference<>(); + + Thread writer = new Thread(() -> { + reg.publishSnapshotByKey(key, new StubSnapshot(new HashMap<>(), 7L)); + published.countDown(); + }); + Thread reader = new Thread(() -> { + try { + published.await(2, TimeUnit.SECONDS); + readBack.set(reg.currentSnapshotByKey(key)); + } catch (InterruptedException ignored) {} + }); + writer.start(); reader.start(); + writer.join(2000); reader.join(2000); + + assertNotNull(readBack.get()); + assertEquals(7L, readBack.get().tickId()); + } + + @Test + void refreshFromConfigAtomicallySwapsRegionList() { + GravityFlipConfig cfg = new GravityFlipConfig(); + cfg.getRegions().add(new GravityFlipRegion("a", box(), true)); + RegionRegistry reg = new RegionRegistry(cfg); + + // Reader captures the immutable list before the swap. + var before = reg.enabled(); + assertEquals(1, before.size()); + + // Mutator swaps via refreshFromConfig. + cfg.getRegions().add(new GravityFlipRegion("b", box(), true)); + reg.refreshFromConfig(cfg); + + var after = reg.enabled(); + assertEquals(2, after.size()); + // The previously-captured list must NOT have been mutated under the reader. + assertEquals(1, before.size()); + } + + /** Minimal RegionSnapshot for the publish/read tests; world() is unused (returns null). */ + private static final class StubSnapshot implements RegionSnapshot { + private final Map>> by; + private final long tick; + StubSnapshot(Map>> by, long tick) { + this.by = by; this.tick = tick; + } + @Override public Map>> byRegion() { return by; } + @Override public long tickId() { return tick; } + @Override public World world() { return null; } + } +}