diff --git a/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java b/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java index 7baf6ec..208a234 100644 --- a/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java +++ b/src/main/java/com/mythlane/gravityflip/GravityFlipPlugin.java @@ -2,9 +2,14 @@ package com.mythlane.gravityflip; import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPluginInit; +import com.hypixel.hytale.server.core.universe.Universe; +import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.util.Config; import com.mythlane.gravityflip.config.GravityFlipConfig; +import com.mythlane.gravityflip.region.RegionRegistry; +import com.mythlane.gravityflip.tick.RegionTickLoop; +import java.util.concurrent.ScheduledFuture; import java.util.logging.Level; /** @@ -29,6 +34,9 @@ public class GravityFlipPlugin extends JavaPlugin { private final Config configHolder = withConfig("regions", GravityFlipConfig.CODEC); + private RegionRegistry registry; + private RegionTickLoop tickLoop; + public GravityFlipPlugin(JavaPluginInit init) { super(init); } @@ -37,17 +45,60 @@ public class GravityFlipPlugin extends JavaPlugin { protected void setup() { // NOTE: do NOT call configHolder.get() here — it blocks until preLoad() completes. // Safe call sites are start() and any later lifecycle phase (incl. tick loop). + // + // World acquisition note (Phase 02-02): the plan called for a PrepareUniverseEvent + // listener that stashes a World reference. Empirically, PrepareUniverseEvent + // (com.hypixel.hytale.server.core.event.events.PrepareUniverseEvent) only carries + // a WorldConfigProvider — it does NOT expose a Universe or World. We therefore use + // a Supplier that resolves Universe.get().getDefaultWorld() lazily on each + // tick (matching the MythWorld WorldBorderManager precedent). Until the universe + // is ready, the supplier returns null and the tick is a no-op. getLogger().at(Level.INFO).log("Gravity Flip enabled"); } + @Override + protected void start() { + super.start(); + GravityFlipConfig cfg = configHolder.get(); + this.registry = new RegionRegistry(cfg, configHolder); + this.tickLoop = new RegionTickLoop(registry, th -> + getLogger().at(Level.WARNING).withCause(th).log("detectTick failed")); + + // Lazy world resolution — see setup() comment. + this.tickLoop.startWithDelay(2_000L, () -> { + Universe u = Universe.get(); + return u == null ? null : u.getDefaultWorld(); + }); + + // TaskRegistry registration: registerTask only accepts ScheduledFuture; the + // scheduler returns ScheduledFuture. Cast via raw types per Mythlane idiom; the + // try/catch falls back to manual shutdown() if registration fails (deterministic + // either way because shutdown() always invokes tickLoop.stop()). + try { + @SuppressWarnings({"unchecked", "rawtypes"}) + ScheduledFuture vf = (ScheduledFuture) tickLoop.future(); + getTaskRegistry().registerTask(vf); + } catch (Throwable ignored) { /* manual shutdown() fallback */ } + + getLogger().at(Level.INFO).log( + "Gravity Flip enabled — %d region(s) loaded, detector @100ms", + cfg.getRegions().size()); + } + @Override protected void shutdown() { + // Stop the detector BEFORE super.shutdown() so no tick races plugin teardown. + if (tickLoop != null) tickLoop.stop(); // No auto-save contract: any mutation made during the session must already // have been persisted via configHolder().save() by the command handler that // performed it. See configHolder() javadoc. getLogger().at(Level.INFO).log("Gravity Flip disabled"); + super.shutdown(); } + /** Exposed for Phase 3 (gravity physics) and Phase 4 (commands). */ + public RegionRegistry regions() { return registry; } + /** * Accessor for the region config holder. SAVE CONTRACT: any * caller that mutates {@code configHolder().get().getRegions()} MUST call diff --git a/src/main/java/com/mythlane/gravityflip/tick/RegionTickLoop.java b/src/main/java/com/mythlane/gravityflip/tick/RegionTickLoop.java new file mode 100644 index 0000000..18025a2 --- /dev/null +++ b/src/main/java/com/mythlane/gravityflip/tick/RegionTickLoop.java @@ -0,0 +1,97 @@ +package com.mythlane.gravityflip.tick; + +import com.hypixel.hytale.server.core.universe.world.World; +import com.mythlane.gravityflip.region.RegionRegistry; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Dedicated single-thread daemon scheduler that polls for entity-in-region detection at 100ms. + * + *

Threading contract: the tick reads {@link RegionRegistry#refreshFor(World)} — + * which itself only consumes the registry's own atomic region-list snapshot, NEVER {@code config.get()} + * (a shared mutable reference). Phase 4 command handlers mutate the in-memory list on the server thread, + * then invoke {@code registry.refreshFromConfig(config.get())} to atomic-swap the region-list snapshot + * the detect thread reads, and {@code config.save().join()} to persist. + * + *

Lifecycle: {@link #stop()} uses the + * {@code shutdown -> awaitTermination(5s) -> shutdownNow} idiom (VotePipe / MythWorld precedent) so a + * plugin reload never leaks the scheduler thread. + */ +public final class RegionTickLoop { + + private static final long INITIAL_DELAY_MS = 2_000L; + private static final long PERIOD_MS = 100L; + + private final ScheduledExecutorService scheduler; + private volatile ScheduledFuture future; + private final Consumer errorHandler; + private final RegionRegistry registry; + + public RegionTickLoop(RegionRegistry registry, Consumer errorHandler) { + this.registry = registry; + this.errorHandler = errorHandler == null ? t -> {} : errorHandler; + this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "GravityFlip-Detect"); + t.setDaemon(true); + return t; + }); + } + + /** Start with the canonical 2s initial delay, polling at 100ms. */ + public void start(World world) { + startWithDelay(INITIAL_DELAY_MS, world); + } + + /** Start with a custom initial delay (used by tests + the plugin's lazy-world-supplier path). */ + public void startWithDelay(long initialDelayMs, World world) { + startWithDelay(initialDelayMs, () -> world); + } + + /** + * Start with a custom initial delay and a {@link Supplier} of the {@link World} to tick. + * + *

The supplier is invoked on every tick — this lets the plugin defer World resolution + * (via {@code Universe.get().getDefaultWorld()}) until after the universe is ready, without + * needing a separate event listener. Each tick a {@code null} supplier result is a no-op. + */ + public void startWithDelay(long initialDelayMs, Supplier worldSupplier) { + Runnable tick = () -> { + World w = worldSupplier.get(); + if (w == null) return; + registry.refreshFor(w); + }; + scheduleGuarded(initialDelayMs, tick); + } + + /** Test-friendly overload: schedule an arbitrary runnable. */ + public void startWithDelay(long initialDelayMs, Runnable tick) { + scheduleGuarded(initialDelayMs, tick); + } + + private void scheduleGuarded(long initialDelayMs, Runnable tick) { + Runnable guarded = () -> { + try { tick.run(); } + catch (Throwable th) { errorHandler.accept(th); } + }; + this.future = scheduler.scheduleAtFixedRate( + guarded, initialDelayMs, PERIOD_MS, TimeUnit.MILLISECONDS); + } + + public void stop() { + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) scheduler.shutdownNow(); + } catch (InterruptedException e) { + scheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + public ScheduledFuture future() { return future; } +} diff --git a/src/test/java/com/mythlane/gravityflip/tick/RegionTickLoopTest.java b/src/test/java/com/mythlane/gravityflip/tick/RegionTickLoopTest.java new file mode 100644 index 0000000..e822e99 --- /dev/null +++ b/src/test/java/com/mythlane/gravityflip/tick/RegionTickLoopTest.java @@ -0,0 +1,61 @@ +package com.mythlane.gravityflip.tick; + +import com.mythlane.gravityflip.config.GravityFlipConfig; +import com.mythlane.gravityflip.region.RegionRegistry; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Scheduler-timing tests for {@link RegionTickLoop}. Use the {@code Runnable} overload so the + * tests don't wait the 2s production initial delay and don't need a real {@code World}. + */ +class RegionTickLoopTest { + + @Test + void firesAtLeastEightTimesInOneSecond() throws Exception { + RegionRegistry reg = new RegionRegistry(new GravityFlipConfig()); + RegionTickLoop loop = new RegionTickLoop(reg, t -> {}); + AtomicInteger count = new AtomicInteger(); + loop.startWithDelay(0L, (Runnable) count::incrementAndGet); + Thread.sleep(1_000); + loop.stop(); + assertTrue(count.get() >= 8, "expected >= 8 ticks in 1s, got " + count.get()); + } + + @Test + void stopCompletesWithinFiveSecondsAndNoMoreThanOneExtraTick() throws Exception { + RegionRegistry reg = new RegionRegistry(new GravityFlipConfig()); + RegionTickLoop loop = new RegionTickLoop(reg, t -> {}); + AtomicInteger count = new AtomicInteger(); + loop.startWithDelay(0L, (Runnable) count::incrementAndGet); + Thread.sleep(300); // ~3 ticks + long t0 = System.nanoTime(); + loop.stop(); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + assertTrue(elapsedMs < 5_000, "stop() took " + elapsedMs + "ms"); + int after = count.get(); + Thread.sleep(300); + assertTrue(count.get() - after <= 1, "scheduler kept running after stop()"); + } + + @Test + void exceptionInTickDoesNotKillScheduler() throws Exception { + RegionRegistry reg = new RegionRegistry(new GravityFlipConfig()); + AtomicReference capturedFirst = new AtomicReference<>(); + RegionTickLoop loop = new RegionTickLoop(reg, t -> capturedFirst.compareAndSet(null, t)); + AtomicInteger count = new AtomicInteger(); + loop.startWithDelay(0L, (Runnable) () -> { + int n = count.incrementAndGet(); + if (n == 1) throw new RuntimeException("boom on first tick"); + }); + Thread.sleep(500); // expect ~5 ticks despite the first throwing + loop.stop(); + assertTrue(count.get() >= 3, "scheduler died after first throw; count=" + count.get()); + assertNotNull(capturedFirst.get(), "errorHandler was not invoked"); + assertEquals("boom on first tick", capturedFirst.get().getMessage()); + } +}