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:
2026-04-23 00:56:12 +02:00
parent 6574c05128
commit 53b43d83c7
3 changed files with 209 additions and 0 deletions
@@ -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());
}
}