Drive idle rings from completions and preserve dimension anchors

This commit is contained in:
ms2k 2026-11-12 20:37:04 +09:00
commit b790ecef6b
5 changed files with 350 additions and 231 deletions

View file

@ -3,10 +3,14 @@ package dev.quorate.tune;
import com.mojang.logging.LogUtils;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.registries.Registries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.Tag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.storage.LevelResource;
@ -14,7 +18,7 @@ import net.neoforged.fml.ModContainer;
import net.neoforged.fml.config.ModConfig;
import net.neoforged.neoforge.common.ModConfigSpec;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.entity.EntityLeaveLevelEvent;
import net.neoforged.neoforge.event.server.ServerStartedEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
@ -30,8 +34,8 @@ import org.slf4j.Logger;
import java.io.UncheckedIOException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
@ -66,74 +70,77 @@ public final class IdleRings {
else LOG.warn("Idle rings require Chunky API version 0");
});
NeoForge.EVENT_BUS.addListener(
(PlayerEvent.PlayerLoggedOutEvent event) -> {
if (current != null && event.getEntity() instanceof ServerPlayer player) {
current.visit(player);
(EntityLeaveLevelEvent event) -> {
if (current != null
&& event.getLevel() instanceof ServerLevel level
&& level.getServer() == current.server
&& event.getEntity() instanceof ServerPlayer player) {
current.visit(player, level.dimension().location().toString());
}
});
NeoForge.EVENT_BUS.addListener(
(ServerTickEvent.Post event) -> {
if (current != null
&& current.server == event.getServer()
&& event.getServer().getTickCount() % 20 == 0) current.tick();
if (current != null && current.server == event.getServer()) {
current.run(current::tick);
}
});
NeoForge.EVENT_BUS.addListener(
(ServerStoppingEvent event) -> {
if (current != null && current.server == event.getServer()) {
event.getServer().getPlayerList().getPlayers().forEach(current::visit);
current.server
.getPlayerList()
.getPlayers()
.forEach(
player ->
current.visit(
player,
player.serverLevel()
.dimension()
.location()
.toString()));
current.close();
current = null;
}
});
}
private enum Phase {
RUNNING,
PAUSED,
CANCELLED,
READY
private sealed interface State {}
private enum Idle implements State {
READY,
YIELDED,
FAILED,
CLOSED
}
private static final class Job {
final Rings.Next next;
final Selection selection;
GenerationTask task;
volatile Phase phase;
CompletableFuture<Void> lod;
private record Scanning(Rings.Next next) implements State {}
Job(Rings.Next next, Selection selection, GenerationTask task, Phase phase) {
this.next = next;
this.selection = selection;
this.task = task;
this.phase = phase;
}
private record Terrain(Rings.Next next, GenerationTask task) implements State {}
boolean matches(Selection other) {
return matches(selection, other);
}
private record Paused(Rings.Next next, GenerationTask task, long until) implements State {}
static boolean matches(Selection selection, Selection other) {
return selection.world().getName().equals(other.world().getName())
&& selection.shape().equals(other.shape())
&& selection.centerX() == other.centerX()
&& selection.centerZ() == other.centerZ()
&& selection.radiusX() == other.radiusX()
&& selection.radiusZ() == other.radiusZ()
&& selection.pattern().getType().equals(other.pattern().getType())
&& selection.pattern().getValue().equals(other.pattern().getValue());
}
private record Backfill(Rings.Next next) implements State {}
synchronized void observe(GenerationTask other, boolean finished) {
if (phase == Phase.PAUSED && task != other && matches(other.getSelection())) {
task = other;
phase = Phase.RUNNING;
}
if (task != other || phase == Phase.CANCELLED) return;
if (finished && !other.isCancelled()) phase = Phase.PAUSED;
else if (finished && other.getChunkIterator().hasNext()) phase = Phase.CANCELLED;
// Chunky's finish event precedes in-flight chunks; complete progress includes them.
else if (other.getProgress().isComplete()) phase = Phase.READY;
}
private record Waiting(long until, double budget) implements State {}
private record Checkpoint(Rings.Next next, boolean paused) {}
private record Update(
GenerationTask task,
boolean finished,
boolean cancelled,
boolean remaining,
boolean complete) {}
private static boolean matches(Selection a, Selection b) {
return a.world().getName().equals(b.world().getName())
&& a.shape().equals(b.shape())
&& a.centerX() == b.centerX()
&& a.centerZ() == b.centerZ()
&& a.radiusX() == b.radiusX()
&& a.radiusZ() == b.radiusZ()
&& a.pattern().getType().equals(b.pattern().getType())
&& a.pattern().getValue().equals(b.pattern().getValue());
}
private static final class Runner implements AutoCloseable {
@ -144,15 +151,12 @@ public final class IdleRings {
final ExecutorService executor =
Executors.newSingleThreadExecutor(
Thread.ofPlatform().name("Saltatory rings").daemon(true).factory());
volatile Job job;
CompletableFuture<Long> usage;
boolean failed;
boolean yielded;
State state = Idle.READY;
boolean wasOnline;
final Consumer<GenerationTaskUpdateEvent> update =
event -> observe(event.generationTask(), false);
event -> receive(event.generationTask(), false);
final Consumer<GenerationTaskFinishEvent> finish =
event -> observe(event.generationTask(), true);
event -> receive(event.generationTask(), true);
Runner(MinecraftServer server, Chunky chunky) {
this.server = server;
@ -163,21 +167,40 @@ public final class IdleRings {
.computeIfAbsent(
new SavedData.Factory<>(Store::new, Store::load, null),
"saltatory_rings");
if (store.pending != null && store.paused) {
job = new Job(store.pending, selection(store.pending), null, Phase.PAUSED);
if (store.checkpoint != null && store.checkpoint.paused) {
var next = store.checkpoint.next;
state = new Paused(next, null, 0);
}
chunky.getEventBus().subscribe(GenerationTaskUpdateEvent.class, update);
chunky.getEventBus().subscribe(GenerationTaskFinishEvent.class, finish);
}
void observe(GenerationTask task, boolean finished) {
var active = job;
if (active != null && server.isRunning()) active.observe(task, finished);
void run(Runnable action) {
if (state == Idle.CLOSED || state == Idle.FAILED) return;
try {
action.run();
} catch (IllegalStateException error) {
fail(error);
}
}
void visit(ServerPlayer player) {
var level = player.serverLevel();
var world = level.dimension().location().toString();
void receive(GenerationTask task, boolean finished) {
var message =
new Update(
task,
finished,
task.isCancelled(),
task.getChunkIterator().hasNext(),
task.getProgress().isComplete());
enqueue(() -> observe(message));
}
void enqueue(Runnable action) {
server.execute(() -> run(action));
}
void visit(ServerPlayer player, String world) {
store.rings =
store.rings.visit(
player.getUUID() + ":" + world,
world,
@ -185,114 +208,174 @@ public final class IdleRings {
player.chunkPosition().z,
Instant.now().getEpochSecond());
store.setDirty();
if (state instanceof Scanning) state = Idle.READY;
}
void tick() {
if (failed) return;
try {
boolean online = server.getPlayerCount() != 0;
if (wasOnline && !online) yielded = false;
if (wasOnline && !online && (state == Idle.YIELDED || state instanceof Waiting)) {
state = Idle.READY;
}
if (online && state instanceof Scanning) state = Idle.READY;
wasOnline = online;
if (job != null) {
if (job.phase == Phase.PAUSED) {
var world = job.selection.world();
var running = chunky.getGenerationTasks().get(world.getName());
if (running != null && job.matches(running.getSelection())) {
job.observe(running, false);
if (state instanceof Paused paused) {
if (server.getTickCount() >= paused.until) reconcile(paused);
return;
}
if (state instanceof Waiting waiting) {
if (server.getTickCount() < waiting.until && DISK.get() == waiting.budget) return;
state = Idle.READY;
}
advance();
}
void observe(Update message) {
if (state instanceof Paused paused
&& paused.next
.cell()
.world()
.equals(message.task.getSelection().world().getName())) {
if (!matches(selection(paused.next), message.task.getSelection())) {
release();
return;
}
if (paused.task == message.task && !message.complete) return;
state = new Terrain(paused.next, message.task);
store.checkpoint(paused.next, false);
}
if (state instanceof Terrain terrain && terrain.task == message.task) {
if (message.finished && !message.cancelled) {
state =
new Paused(
terrain.next, terrain.task, (long) server.getTickCount() + 20);
store.checkpoint(terrain.next, true);
} else if (message.finished && message.remaining) {
release();
} else if (message.complete) {
backfill(terrain.next);
}
} else {
var saved = chunky.getTaskLoader().loadTask(world);
if (state instanceof Waiting) state = Idle.READY;
advance();
}
}
void reconcile(Paused paused) {
var selected = selection(paused.next);
var world = selected.world();
var running = chunky.getGenerationTasks().get(world.getName());
if (running != null
&& running != paused.task
&& matches(selected, running.getSelection())) {
state = new Terrain(paused.next, running);
store.checkpoint(paused.next, false);
if (running.getProgress().isComplete()) backfill(paused.next);
return;
}
// Chunky emits no event when a paused checkpoint is cancelled.
var saved = chunky.getTaskLoader().loadTask(world);
if ((running != null && running != paused.task)
|| saved.isEmpty()
|| saved.get().isCancelled()
|| !job.matches(saved.get().getSelection())) {
|| !matches(selected, saved.get().getSelection())) {
release();
return;
} else {
state = new Paused(paused.next, paused.task, (long) server.getTickCount() + 20);
}
}
boolean eligible() {
return DISK.get() != 0
&& server.getPlayerCount() == 0
&& chunky.getGenerationTasks().isEmpty();
}
boolean paused = job.phase == Phase.PAUSED;
if (store.paused != paused) {
store.paused = paused;
store.setDirty();
boolean available(Rings.Next next) {
var selected = selection(next);
var saved = chunky.getTaskLoader().loadTask(selected.world());
if (saved.isPresent()
&& !saved.get().isCancelled()
&& (store.checkpoint == null
|| !matches(selected, saved.get().getSelection()))) {
state = new Waiting((long) server.getTickCount() + 20, DISK.get());
return false;
}
if (job.phase == Phase.CANCELLED) {
release();
return;
return true;
}
if (job.phase != Phase.READY) return;
if (job.lod == null) {
var level = server.getAllLevels().iterator();
while (level.hasNext()) {
var candidate = level.next();
if (candidate
.dimension()
.location()
.toString()
.equals(job.next.cell().world())) {
candidate.getChunkSource().save(true);
job.lod = lod.fill(candidate, job.next.cell(), executor);
return;
}
}
throw new IllegalStateException("Ring dimension is not loaded");
}
if (!job.lod.isDone()) return;
job.lod.join();
store.rings.complete(job.next);
store.pending = null;
store.setDirty();
LOG.debug("Completed idle pregen batch {}", job.next.cell());
job = null;
usage = null;
return;
}
if (DISK.get() == 0 || online || yielded) return;
if (!chunky.getGenerationTasks().isEmpty()) return;
void advance() {
if (state != Idle.READY || !eligible()) return;
var next =
store.pending != null
? store.pending
store.checkpoint != null
? store.checkpoint.next
: store.rings
.next(Instant.now().getEpochSecond() - DAYS.get() * 86400L)
.orElse(null);
if (next == null) return;
if (usage == null) {
usage =
if (next == null || !available(next)) return;
var scan = new Scanning(next);
state = scan;
var root = server.getWorldPath(LevelResource.ROOT);
CompletableFuture.supplyAsync(
() -> {
try {
return Rings.bytes(
server.getWorldPath(LevelResource.ROOT));
return Rings.bytes(root);
} catch (java.io.IOException error) {
throw new UncheckedIOException(error);
}
},
executor);
return;
}
if (!usage.isDone()) return;
long bytes = usage.join();
usage = null;
if (!Rings.below(bytes, DISK.get())) return;
var selection = selection(next);
var world = chunky.getServer().getWorld(next.cell().world()).orElseThrow();
var saved = chunky.getTaskLoader().loadTask(world);
if (saved.isPresent() && !saved.get().isCancelled()) {
if (store.pending == null
|| !Job.matches(selection, saved.get().getSelection())) return;
}
var task = new GenerationTask(chunky, selection);
job = new Job(next, selection, task, Phase.RUNNING);
store.pending = next;
store.setDirty();
chunky.getGenerationTasks().put(world.getName(), task);
chunky.getScheduler().runTask(task);
} catch (CompletionException | IllegalStateException error) {
failed = true;
LOG.error("Idle pregen stopped", error);
}
executor)
.whenComplete((bytes, error) -> enqueue(() -> measured(scan, bytes, error)));
}
private Selection selection(Rings.Next next) {
void measured(Scanning scan, Long bytes, Throwable error) {
if (state != scan) return;
if (error != null) {
fail(error);
return;
}
state = Idle.READY;
if (!eligible()) return;
if (!Rings.below(bytes, DISK.get())) {
// External file deletion can free space without a server event.
state = new Waiting((long) server.getTickCount() + 1200, DISK.get());
return;
}
if (!available(scan.next)) return;
var task = new GenerationTask(chunky, selection(scan.next));
state = new Terrain(scan.next, task);
store.checkpoint(scan.next, false);
chunky.getGenerationTasks().put(task.getSelection().world().getName(), task);
chunky.getScheduler().runTask(task);
}
void backfill(Rings.Next next) {
var key =
ResourceKey.create(
Registries.DIMENSION, ResourceLocation.parse(next.cell().world()));
var level = server.getLevel(key);
if (level == null) throw new IllegalStateException("Ring dimension is not loaded");
var backfill = new Backfill(next);
state = backfill;
store.checkpoint(next, false);
level.getChunkSource().save(true);
lod.fill(level, next.cell(), executor)
.whenComplete((ignored, error) -> enqueue(() -> filled(backfill, error)));
}
void filled(Backfill backfill, Throwable error) {
if (state != backfill) return;
if (error != null) {
fail(error);
return;
}
store.rings = store.rings.complete(backfill.next);
store.checkpoint(null, false);
LOG.debug("Completed idle pregen batch {}", backfill.next.cell());
state = Idle.READY;
advance();
}
Selection selection(Rings.Next next) {
var world = chunky.getServer().getWorld(next.cell().world()).orElseThrow();
return Selection.builder(chunky, world)
.shape("square")
@ -302,13 +385,14 @@ public final class IdleRings {
.build();
}
private void release() {
job = null;
store.pending = null;
store.paused = false;
store.setDirty();
yielded = true;
usage = null;
void release() {
store.checkpoint(null, false);
state = Idle.YIELDED;
}
void fail(Throwable error) {
state = Idle.FAILED;
LOG.error("Idle pregen stopped", error);
}
private static double center(int cell) {
@ -317,40 +401,45 @@ public final class IdleRings {
@Override
public void close() {
state = Idle.CLOSED;
chunky.getEventBus().unsubscribe(GenerationTaskUpdateEvent.class, update);
chunky.getEventBus().unsubscribe(GenerationTaskFinishEvent.class, finish);
if (job != null) {
store.paused = job.phase == Phase.PAUSED;
store.setDirty();
}
job = null;
executor.shutdownNow();
lod.close();
}
}
private static final class Store extends SavedData {
final Rings rings = new Rings();
Rings.Next pending;
boolean paused;
Rings rings = new Rings();
Checkpoint checkpoint;
void checkpoint(Rings.Next next, boolean paused) {
checkpoint = next == null ? null : new Checkpoint(next, paused);
setDirty();
}
static Store load(CompoundTag tag, HolderLookup.Provider registries) {
var store = new Store();
store.paused = tag.getBoolean("paused");
var sites = new LinkedHashMap<String, Rings.Site>();
for (var entry : tag.getList("sites", Tag.TAG_COMPOUND)) {
var site = (CompoundTag) entry;
store.rings.sites.put(site.getString("owner"), site(site));
sites.put(site.getString("owner"), site(site));
}
store.rings = new Rings(sites);
if (tag.contains("pending", Tag.TAG_COMPOUND)) {
var data = tag.getCompound("pending");
var site = site(data);
var offset = Rings.ring(site.ring()).get(site.index());
store.pending =
store.checkpoint =
new Checkpoint(
new Rings.Next(
data.getString("owner"),
site,
new Rings.Cell(
site.world(), site.x() + offset[0], site.z() + offset[1]));
site.world(),
site.x() + offset[0],
site.z() + offset[1])),
tag.getBoolean("paused"));
}
return store;
}
@ -382,10 +471,12 @@ public final class IdleRings {
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
var sites = new ListTag();
rings.sites.forEach((owner, site) -> sites.add(tag(owner, site)));
rings.sites().forEach((owner, site) -> sites.add(tag(owner, site)));
tag.put("sites", sites);
tag.putBoolean("paused", paused);
if (pending != null) tag.put("pending", tag(pending.owner(), pending.site()));
tag.putBoolean("paused", checkpoint != null && checkpoint.paused);
if (checkpoint != null) {
tag.put("pending", tag(checkpoint.next.owner(), checkpoint.next.site()));
}
return tag;
}
}

View file

@ -37,20 +37,15 @@ public final class LodBatch implements AutoCloseable {
var target = dh;
int minX = cell.x() * Rings.WIDTH;
int minZ = cell.z() * Rings.WIDTH;
CompletableFuture<Void> pending = CompletableFuture.completedFuture(null);
var pending = new ArrayList<CompletableFuture<Void>>();
for (int x = Math.floorDiv(minX, 4); x <= Math.floorDiv(minX + Rings.WIDTH - 1, 4); x++) {
for (int z = Math.floorDiv(minZ, 4);
z <= Math.floorDiv(minZ + Rings.WIDTH - 1, 4);
z++) {
int tileX = x;
int tileZ = z;
pending =
pending.thenComposeAsync(
ignored -> tile(target, generator, cell, tileX, tileZ, executor),
executor);
pending.add(tile(target, generator, cell, x, z, executor));
}
}
return pending;
return CompletableFuture.allOf(pending.toArray(CompletableFuture[]::new));
}
private static CompletableFuture<Void> tile(

View file

@ -10,7 +10,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class Rings {
public record Rings(Map<String, Rings.Site> sites) {
public static final int WIDTH = 9;
public record Cell(String world, int x, int z) {}
@ -19,15 +19,21 @@ public final class Rings {
public record Next(String owner, Site site, Cell cell) {}
public final Map<String, Site> sites = new LinkedHashMap<>();
private long sequence;
public Rings {
sites = Map.copyOf(sites);
}
public void visit(String owner, String world, int chunkX, int chunkZ, long now) {
public Rings() {
this(Map.of());
}
public Rings visit(String owner, String world, int chunkX, int chunkZ, long now) {
int x = Math.floorDiv(chunkX, WIDTH);
int z = Math.floorDiv(chunkZ, WIDTH);
var old = sites.get(owner);
boolean same = old != null && old.world.equals(world) && old.x == x && old.z == z;
sites.put(
var updated = new LinkedHashMap<>(sites);
updated.put(
owner,
new Site(
world,
@ -37,6 +43,7 @@ public final class Rings {
same ? old.index : 0,
now,
same ? old.served : 0));
return new Rings(updated);
}
public Optional<Next> next(long since) {
@ -58,7 +65,7 @@ public final class Rings {
});
}
public boolean complete(Next next) {
public Rings complete(Next next) {
var old = sites.get(next.owner);
var started = next.site;
if (old == null
@ -66,18 +73,18 @@ public final class Rings {
|| old.x != started.x
|| old.z != started.z
|| old.ring != started.ring
|| old.index != started.index) return false;
|| old.index != started.index) return this;
int index = old.index + 1;
int radius = old.ring;
if (index == ring(radius).size()) {
radius++;
index = 0;
}
sequence =
Math.max(sequence, sites.values().stream().mapToLong(Site::served).max().orElse(0));
sites.put(
next.owner, new Site(old.world, old.x, old.z, radius, index, old.seen, ++sequence));
return true;
long sequence = sites.values().stream().mapToLong(Site::served).max().orElse(0) + 1;
var updated = new LinkedHashMap<>(sites);
updated.put(
next.owner, new Site(old.world, old.x, old.z, radius, index, old.seen, sequence));
return new Rings(updated);
}
public static List<int[]> ring(int radius) {

View file

@ -53,6 +53,9 @@ public abstract class RefineQueue implements LodWork.Queue {
mapper,
reducer,
pos -> DhSectionPos.getDetailLevel(pos) == 6,
() -> inProgressGenTasksByLodPos.isEmpty() && refine.saltatory$ready());
() ->
inProgressGenTasksByLodPos.keySet().stream()
.allMatch(pos -> DhSectionPos.getDetailLevel(pos) == 6)
&& refine.saltatory$ready());
}
}

View file

@ -2,6 +2,8 @@ package dev.quorate.tune;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
@ -32,45 +34,66 @@ final class RingsTest {
@Test
void locationsUseTheSameGridAcrossZero() {
var rings = new Rings();
rings.visit("a", "world", -1, -10, 1);
rings = rings.visit("a", "world", -1, -10, 1);
assertEquals(new Rings.Cell("world", -1, -2), rings.next(0).orElseThrow().cell());
}
@Test
void nearbyCoveragePrecedesLargerRingsAndPlayersTakeTurns() {
var rings = new Rings();
rings.visit("a", "world", 0, 0, 10);
rings.visit("b", "world", 90, 90, 10);
rings = rings.visit("a", "world", 0, 0, 10);
rings = rings.visit("b", "world", 90, 90, 10);
var first = rings.next(0).orElseThrow();
assertEquals("a", first.owner());
assertTrue(rings.complete(first));
var advanced = rings.complete(first);
assertNotSame(rings, advanced);
rings = advanced;
assertEquals("b", rings.next(0).orElseThrow().owner());
rings.complete(rings.next(0).orElseThrow());
rings.complete(rings.next(0).orElseThrow());
rings = rings.complete(rings.next(0).orElseThrow());
rings = rings.complete(rings.next(0).orElseThrow());
assertEquals("b", rings.next(0).orElseThrow().owner());
}
@Test
void completionRetainsActivityButDoesNotAdvanceAMovedLocation() {
var rings = new Rings();
rings.visit("a", "world", 0, 0, 10);
rings = rings.visit("a", "world", 0, 0, 10);
var first = rings.next(0).orElseThrow();
rings.visit("a", "world", 1, 1, 20);
assertTrue(rings.complete(first));
assertEquals(20, rings.sites.get("a").seen());
rings = rings.visit("a", "world", 1, 1, 20);
rings = rings.complete(first);
assertEquals(20, rings.sites().get("a").seen());
var second = rings.next(0).orElseThrow();
rings.visit("a", "world", 100, 100, 30);
assertFalse(rings.complete(second));
assertEquals(0, rings.sites.get("a").ring());
rings = rings.visit("a", "world", 100, 100, 30);
assertSame(rings, rings.complete(second));
assertEquals(0, rings.sites().get("a").ring());
}
@Test
void oldLocationsAreNotExpanded() {
var rings = new Rings();
rings.visit("a", "world", 0, 0, 10);
rings = rings.visit("a", "world", 0, 0, 10);
assertTrue(rings.next(11).isEmpty());
}
@Test
void dimensionAnchors() {
var old =
new Rings()
.visit("a:overworld", "overworld", 90, 90, 10)
.visit("a:nether", "nether", -90, -90, 20);
var next = old.visit("a:overworld", "overworld", 180, 180, 30);
assertEquals(old.sites().get("a:nether"), next.sites().get("a:nether"));
assertEquals(10, old.sites().get("a:overworld").x());
assertEquals(20, next.sites().get("a:overworld").x());
for (int i = 0; i < 50; i++) {
var batch = next.next(0).orElseThrow();
next = next.complete(batch);
int overworld = next.sites().get("a:overworld").ring();
int nether = next.sites().get("a:nether").ring();
assertTrue(Math.abs(overworld - nether) <= 1);
}
}
@Test
void diskBudgetUsesGibAndZeroDisablesGeneration() {
assertFalse(Rings.below(0, 0));