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:
@@ -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<RegionSnapshot>} 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; }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
*
|
||||
* <p>Published by {@code RegionRegistry.refreshFor(world)} via an {@code AtomicReference}
|
||||
* so off-thread consumers (Phase 3 physics) can read a stable snapshot without locking.
|
||||
*
|
||||
* <p><strong>Contract for consumers (Phase 3):</strong> {@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<GravityFlipRegion, Collection<Ref<EntityStore>>> 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();
|
||||
}
|
||||
Reference in New Issue
Block a user