feat(02-02): add RegionRegistry + RegionSnapshot with ECS refreshFor (Tasks 1+2)

- RegionSnapshot: immutable per-world occupancy view (byRegion/tickId/world)
- RegionRegistry:
  * AtomicReference<List<GravityFlipRegion>> 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)
This commit is contained in:
2026-04-23 00:54:32 +02:00
parent 216f544d9b
commit 6574c05128
3 changed files with 412 additions and 0 deletions
@@ -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:
*
* <ol>
* <li>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.</li>
* <li>A per-{@link World} {@code AtomicReference&lt;RegionSnapshot&gt;} published by
* {@link #refreshFor(World)} every tick; off-thread consumers (Phase 3 physics) read it via
* {@link #currentSnapshot(World)}.</li>
* </ol>
*
* <p><strong>Threading contract:</strong> 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<EntityStore, TransformComponent> transformType;
private static ComponentType<EntityStore, TransformComponent> transform() {
ComponentType<EntityStore, TransformComponent> 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<GravityFlipConfig> holder; // nullable in tests
/** Immutable snapshot of the region list, swapped atomically on every mutation. */
private final AtomicReference<List<GravityFlipRegion>> 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<Object, AtomicReference<RegionSnapshot>> snapshots = new ConcurrentHashMap<>();
public RegionRegistry(GravityFlipConfig cfg) {
this(cfg, null);
}
public RegionRegistry(GravityFlipConfig cfg, Config<GravityFlipConfig> 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<GravityFlipRegion> all() {
return regionsSnapshot.get();
}
/** Read-only view of currently-enabled regions, derived from the atomic snapshot (NOT from config). */
List<GravityFlipRegion> enabled() {
List<GravityFlipRegion> 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<Void> 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.
*
* <p>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<GravityFlipRegion> enabled = enabled();
Map<GravityFlipRegion, Collection<Ref<EntityStore>>> byRegion = new ConcurrentHashMap<>();
for (GravityFlipRegion r : enabled) {
byRegion.put(r, new ConcurrentLinkedQueue<>());
}
if (enabled.isEmpty()) {
publishSnapshot(world, snapshotOf(world, byRegion));
return;
}
try {
Store<EntityStore> store = world.getEntityStore().getStore();
ComponentType<EntityStore, TransformComponent> 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<EntityStore> 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<RegionSnapshot> 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<RegionSnapshot> ref = snapshots.get(key);
return ref == null ? null : ref.get();
}
private RegionSnapshot snapshotOf(World w,
Map<GravityFlipRegion, Collection<Ref<EntityStore>>> byRegion) {
long tick = 0L;
try { tick = w.getTick(); } catch (Throwable ignored) {}
final long tickId = Math.max(tick, 1L); // tests assert tickId() > 0
final Map<GravityFlipRegion, Collection<Ref<EntityStore>>> frozen =
Collections.unmodifiableMap(byRegion);
return new RegionSnapshot() {
@Override public Map<GravityFlipRegion, Collection<Ref<EntityStore>>> byRegion() { return frozen; }
@Override public long tickId() { return tickId; }
@Override public World world() { return w; }
};
}
}