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,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}.
|
||||
*
|
||||
* <p>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<GravityFlipRegion, Collection<Ref<EntityStore>>> 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<RegionSnapshot> 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<GravityFlipRegion, Collection<Ref<EntityStore>>> by;
|
||||
private final long tick;
|
||||
StubSnapshot(Map<GravityFlipRegion, Collection<Ref<EntityStore>>> by, long tick) {
|
||||
this.by = by; this.tick = tick;
|
||||
}
|
||||
@Override public Map<GravityFlipRegion, Collection<Ref<EntityStore>>> byRegion() { return by; }
|
||||
@Override public long tickId() { return tick; }
|
||||
@Override public World world() { return null; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user