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:
@@ -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