feat(02-02): add RegionTickLoop + plugin wiring (Task 3)
- RegionTickLoop: single-thread daemon scheduler @100ms, 2s initial delay,
shutdown -> awaitTermination(5s) -> shutdownNow lifecycle.
Three start overloads: (World), (Supplier<World>), (Runnable). Errors are
routed to a Consumer<Throwable> so an exception in one tick never kills
the scheduler.
- GravityFlipPlugin.start():
* builds RegionRegistry from configHolder.get() + holder for save()
* starts the tick loop with a Supplier<World> that lazily resolves
Universe.get().getDefaultWorld() (deviation: PrepareUniverseEvent in the
pinned API only carries WorldConfigProvider — no Universe/World access,
so we use the lazy-supplier pattern from MythWorld instead)
* registers the ScheduledFuture with TaskRegistry (raw cast, try/catch fallback)
- GravityFlipPlugin.shutdown(): tickLoop.stop() BEFORE super.shutdown()
- RegionTickLoopTest: 3 timing tests pass (>=8 ticks/sec, stop within 5s,
exception resilience).
- gradle build green; fat jar contains both region/ and tick/ class dirs.
This commit is contained in:
@@ -2,9 +2,14 @@ package com.mythlane.gravityflip;
|
|||||||
|
|
||||||
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
|
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
|
||||||
import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
|
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.hypixel.hytale.server.core.util.Config;
|
||||||
import com.mythlane.gravityflip.config.GravityFlipConfig;
|
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;
|
import java.util.logging.Level;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +34,9 @@ public class GravityFlipPlugin extends JavaPlugin {
|
|||||||
private final Config<GravityFlipConfig> configHolder =
|
private final Config<GravityFlipConfig> configHolder =
|
||||||
withConfig("regions", GravityFlipConfig.CODEC);
|
withConfig("regions", GravityFlipConfig.CODEC);
|
||||||
|
|
||||||
|
private RegionRegistry registry;
|
||||||
|
private RegionTickLoop tickLoop;
|
||||||
|
|
||||||
public GravityFlipPlugin(JavaPluginInit init) {
|
public GravityFlipPlugin(JavaPluginInit init) {
|
||||||
super(init);
|
super(init);
|
||||||
}
|
}
|
||||||
@@ -37,17 +45,60 @@ public class GravityFlipPlugin extends JavaPlugin {
|
|||||||
protected void setup() {
|
protected void setup() {
|
||||||
// NOTE: do NOT call configHolder.get() here — it blocks until preLoad() completes.
|
// 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).
|
// 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<World> 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");
|
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<Void>; 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<Void> 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
|
@Override
|
||||||
protected void shutdown() {
|
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
|
// No auto-save contract: any mutation made during the session must already
|
||||||
// have been persisted via configHolder().save() by the command handler that
|
// have been persisted via configHolder().save() by the command handler that
|
||||||
// performed it. See configHolder() javadoc.
|
// performed it. See configHolder() javadoc.
|
||||||
getLogger().at(Level.INFO).log("Gravity Flip disabled");
|
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. <strong>SAVE CONTRACT:</strong> any
|
* Accessor for the region config holder. <strong>SAVE CONTRACT:</strong> any
|
||||||
* caller that mutates {@code configHolder().get().getRegions()} MUST call
|
* caller that mutates {@code configHolder().get().getRegions()} MUST call
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p><strong>Threading contract:</strong> 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.
|
||||||
|
*
|
||||||
|
* <p><strong>Lifecycle:</strong> {@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<Throwable> errorHandler;
|
||||||
|
private final RegionRegistry registry;
|
||||||
|
|
||||||
|
public RegionTickLoop(RegionRegistry registry, Consumer<Throwable> 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.
|
||||||
|
*
|
||||||
|
* <p>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<World> 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; }
|
||||||
|
}
|
||||||
@@ -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<Throwable> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user