Generate idle Chunky rings and backfill their saved LOD

This commit is contained in:
ms2k 2026-11-12 17:16:51 +09:00
commit dc2f9408a1
11 changed files with 778 additions and 2 deletions

View file

@ -54,6 +54,7 @@ dependencies {
compileOnly('curse.maven:functional-storage-556861:8459097') { transitive = false }
compileOnly('maven.modrinth:refined-storage:lHHiI26k') { transitive = false }
compileOnly('maven.modrinth:distanthorizons:9w34y8ai') { transitive = false }
compileOnly('maven.modrinth:chunky:LuFhm4eU') { transitive = false }
compileOnly('com.pockyl:dhturbo-neoforge-1.21.1:1.5.0') { transitive = false }
compileOnly 'org.jetbrains:annotations:24.1.0'
compileOnly 'org.apiguardian:apiguardian-api:1.1.2'

View file

@ -0,0 +1,392 @@
package dev.quorate.tune;
import com.mojang.logging.LogUtils;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.Tag;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.storage.LevelResource;
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.server.ServerStartedEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
import org.popcraft.chunky.Chunky;
import org.popcraft.chunky.ChunkyProvider;
import org.popcraft.chunky.GenerationTask;
import org.popcraft.chunky.Selection;
import org.popcraft.chunky.event.task.GenerationTaskFinishEvent;
import org.popcraft.chunky.event.task.GenerationTaskUpdateEvent;
import org.popcraft.chunky.util.Parameter;
import org.slf4j.Logger;
import java.io.UncheckedIOException;
import java.time.Instant;
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;
public final class IdleRings {
private static final Logger LOG = LogUtils.getLogger();
private static final ModConfigSpec.DoubleValue DISK;
private static final ModConfigSpec.IntValue DAYS;
private static final ModConfigSpec SPEC;
private static Runner current;
static {
var builder = new ModConfigSpec.Builder();
DISK =
builder.comment("World-file budget in GiB. Zero disables background generation.")
.defineInRange("disk_gib", 0.0, 0.0, 1_000_000.0);
DAYS =
builder.comment("Player locations older than this are not expanded.")
.defineInRange("recent_days", 7, 1, 36500);
SPEC = builder.build();
}
private IdleRings() {}
public static void register(ModContainer mod) {
mod.registerConfig(ModConfig.Type.COMMON, SPEC, "saltatory.toml");
NeoForge.EVENT_BUS.addListener(
(ServerStartedEvent event) -> {
var chunky = ChunkyProvider.get();
if (chunky.getApi().version() == 0)
current = new Runner(event.getServer(), chunky);
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);
}
});
NeoForge.EVENT_BUS.addListener(
(ServerTickEvent.Post event) -> {
if (current != null
&& current.server == event.getServer()
&& event.getServer().getTickCount() % 20 == 0) current.tick();
});
NeoForge.EVENT_BUS.addListener(
(ServerStoppingEvent event) -> {
if (current != null && current.server == event.getServer()) {
event.getServer().getPlayerList().getPlayers().forEach(current::visit);
current.close();
current = null;
}
});
}
private enum Phase {
RUNNING,
PAUSED,
CANCELLED,
READY
}
private static final class Job {
final Rings.Next next;
final Selection selection;
GenerationTask task;
volatile Phase phase;
CompletableFuture<Void> lod;
Job(Rings.Next next, Selection selection, GenerationTask task, Phase phase) {
this.next = next;
this.selection = selection;
this.task = task;
this.phase = phase;
}
boolean matches(Selection other) {
return matches(selection, other);
}
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());
}
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 static final class Runner implements AutoCloseable {
final MinecraftServer server;
final Chunky chunky;
final Store store;
final LodBatch lod = new LodBatch();
final ExecutorService executor =
Executors.newSingleThreadExecutor(
Thread.ofPlatform().name("Saltatory rings").daemon(true).factory());
volatile Job job;
CompletableFuture<Long> usage;
boolean failed;
boolean yielded;
boolean wasOnline;
final Consumer<GenerationTaskUpdateEvent> update =
event -> observe(event.generationTask(), false);
final Consumer<GenerationTaskFinishEvent> finish =
event -> observe(event.generationTask(), true);
Runner(MinecraftServer server, Chunky chunky) {
this.server = server;
this.chunky = chunky;
store =
server.overworld()
.getDataStorage()
.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);
}
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 visit(ServerPlayer player) {
var level = player.serverLevel();
var world = level.dimension().location().toString();
store.rings.visit(
player.getUUID() + ":" + world,
world,
player.chunkPosition().x,
player.chunkPosition().z,
Instant.now().getEpochSecond());
store.setDirty();
}
void tick() {
if (failed) return;
try {
boolean online = server.getPlayerCount() != 0;
if (wasOnline && !online) yielded = false;
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);
} else {
var saved = chunky.getTaskLoader().loadTask(world);
if (running != null
|| saved.isEmpty()
|| saved.get().isCancelled()
|| !job.matches(saved.get().getSelection())) {
release();
return;
}
}
}
boolean paused = job.phase == Phase.PAUSED;
if (store.paused != paused) {
store.paused = paused;
store.setDirty();
}
if (job.phase == Phase.CANCELLED) {
release();
return;
}
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;
var next =
store.pending != null
? store.pending
: store.rings
.next(Instant.now().getEpochSecond() - DAYS.get() * 86400L)
.orElse(null);
if (next == null) return;
if (usage == null) {
usage =
CompletableFuture.supplyAsync(
() -> {
try {
return Rings.bytes(
server.getWorldPath(LevelResource.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);
}
}
private Selection selection(Rings.Next next) {
var world = chunky.getServer().getWorld(next.cell().world()).orElseThrow();
return Selection.builder(chunky, world)
.shape("square")
.center(center(next.cell().x()), center(next.cell().z()))
.radius(64)
.pattern(Parameter.of("concentric"))
.build();
}
private void release() {
job = null;
store.pending = null;
store.paused = false;
store.setDirty();
yielded = true;
usage = null;
}
private static double center(int cell) {
return (double) (cell * Rings.WIDTH + Rings.WIDTH / 2) * 16 + 8;
}
@Override
public void close() {
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;
static Store load(CompoundTag tag, HolderLookup.Provider registries) {
var store = new Store();
store.paused = tag.getBoolean("paused");
for (var entry : tag.getList("sites", Tag.TAG_COMPOUND)) {
var site = (CompoundTag) entry;
store.rings.sites.put(site.getString("owner"), site(site));
}
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 =
new Rings.Next(
data.getString("owner"),
site,
new Rings.Cell(
site.world(), site.x() + offset[0], site.z() + offset[1]));
}
return store;
}
private static Rings.Site site(CompoundTag tag) {
return new Rings.Site(
tag.getString("world"),
tag.getInt("x"),
tag.getInt("z"),
tag.getInt("ring"),
tag.getInt("index"),
tag.getLong("seen"),
tag.getLong("served"));
}
private static CompoundTag tag(String owner, Rings.Site site) {
var tag = new CompoundTag();
tag.putString("owner", owner);
tag.putString("world", site.world());
tag.putInt("x", site.x());
tag.putInt("z", site.z());
tag.putInt("ring", site.ring());
tag.putInt("index", site.index());
tag.putLong("seen", site.seen());
tag.putLong("served", site.served());
return tag;
}
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
var sites = new ListTag();
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()));
return tag;
}
}
}

View file

@ -0,0 +1,117 @@
package dev.quorate.tune;
import com.seibel.distanthorizons.api.enums.worldGeneration.EDhApiDistantGeneratorMode;
import com.seibel.distanthorizons.api.enums.worldGeneration.EDhApiWorldGenerationStep;
import com.seibel.distanthorizons.core.api.internal.SharedApi;
import com.seibel.distanthorizons.core.dataObjects.fullData.sources.FullDataSourceV2;
import com.seibel.distanthorizons.core.dataObjects.transformers.LodDataBuilder;
import com.seibel.distanthorizons.core.dependencyInjection.SingletonInjector;
import com.seibel.distanthorizons.core.level.AbstractDhServerLevel;
import com.seibel.distanthorizons.core.pos.DhSectionPos;
import com.seibel.distanthorizons.core.wrapperInterfaces.IWrapperFactory;
import com.seibel.distanthorizons.core.wrapperInterfaces.chunk.IChunkWrapper;
import com.seibel.distanthorizons.core.wrapperInterfaces.worldGeneration.IChunkGenerator;
import net.minecraft.server.level.ServerLevel;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
public final class LodBatch implements AutoCloseable {
private final Map<AbstractDhServerLevel, IChunkGenerator> generators = new HashMap<>();
public CompletableFuture<Void> fill(
ServerLevel level, Rings.Cell cell, ExecutorService executor) {
AbstractDhServerLevel dh = null;
for (var candidate : SharedApi.getAbstractDhWorld().getAllLoadedLevels()) {
if (candidate instanceof AbstractDhServerLevel server
&& server.getLevelWrapper().getWrappedMcObject() == level) dh = server;
}
if (dh == null) throw new IllegalStateException("DH level is not loaded: " + cell.world());
var factory = SingletonInjector.INSTANCE.get(IWrapperFactory.class);
var generator = generators.computeIfAbsent(dh, factory::createChunkGenerator);
generator.updateAllFutures();
var target = dh;
int minX = cell.x() * Rings.WIDTH;
int minZ = cell.z() * Rings.WIDTH;
CompletableFuture<Void> pending = CompletableFuture.completedFuture(null);
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);
}
}
return pending;
}
private static CompletableFuture<Void> tile(
AbstractDhServerLevel level,
IChunkGenerator generator,
Rings.Cell cell,
int x,
int z,
ExecutorService executor) {
var data = FullDataSourceV2.createEmpty(DhSectionPos.encode((byte) 6, x, z));
var chunks = new ArrayList<IChunkWrapper>(16);
// Existing-chunk conversion remains available while Chunky suspends distant generation.
return generator
.queueGenEvent(
x * 4,
z * 4,
4,
EDhApiDistantGeneratorMode.PRE_EXISTING_ONLY,
EDhApiWorldGenerationStep.EMPTY,
executor,
chunks::add)
.thenCompose(
ignored -> {
for (var chunk : chunks) {
try (var source =
LodDataBuilder.createFromChunk(
level.getLevelWrapper(), chunk)) {
if (source != null) data.updateFromDataSource(source);
}
}
data.recordLastSeen();
check(data, cell);
return level.getFullDataProvider().updateDataSourceAsync(data);
})
.whenComplete((ignored, error) -> data.close());
}
private static void check(FullDataSourceV2 data, Rings.Cell cell) {
int baseX = DhSectionPos.getMinCornerBlockX(data.getPos());
int baseZ = DhSectionPos.getMinCornerBlockZ(data.getPos());
int minX = Math.max(0, cell.x() * Rings.WIDTH * 16 - baseX);
int minZ = Math.max(0, cell.z() * Rings.WIDTH * 16 - baseZ);
int maxX = Math.min(64, (cell.x() + 1) * Rings.WIDTH * 16 - baseX);
int maxZ = Math.min(64, (cell.z() + 1) * Rings.WIDTH * 16 - baseZ);
for (int x = minX; x < maxX; x++)
for (int z = minZ; z < maxZ; z++) {
var column = data.getColumnAtRelPos(x, z);
int index = FullDataSourceV2.relativePosToIndex(x, z);
if (column == null
|| column.isEmpty()
|| data.columnGenerationSteps.getByte(index)
< EDhApiWorldGenerationStep.LIGHT.value) {
throw new IllegalStateException(
"Missing saved-chunk LOD at " + (baseX + x) + "," + (baseZ + z));
}
}
}
@Override
public void close() {
generators.values().forEach(IChunkGenerator::close);
generators.clear();
}
}

View file

@ -0,0 +1,119 @@
package dev.quorate.tune;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class Rings {
public static final int WIDTH = 9;
public record Cell(String world, int x, int z) {}
public record Site(String world, int x, int z, int ring, int index, long seen, long served) {}
public record Next(String owner, Site site, Cell cell) {}
public final Map<String, Site> sites = new LinkedHashMap<>();
private long sequence;
public void 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(
owner,
new Site(
world,
x,
z,
same ? old.ring : 0,
same ? old.index : 0,
now,
same ? old.served : 0));
}
public Optional<Next> next(long since) {
return sites.entrySet().stream()
.filter(entry -> entry.getValue().seen >= since)
.min(
Comparator.<Map.Entry<String, Site>>comparingInt(
entry -> entry.getValue().ring)
.thenComparingLong(entry -> entry.getValue().served)
.thenComparing(Map.Entry::getKey))
.map(
entry -> {
var site = entry.getValue();
var offset = ring(site.ring).get(site.index);
return new Next(
entry.getKey(),
site,
new Cell(site.world, site.x + offset[0], site.z + offset[1]));
});
}
public boolean complete(Next next) {
var old = sites.get(next.owner);
var started = next.site;
if (old == null
|| !old.world.equals(started.world)
|| old.x != started.x
|| old.z != started.z
|| old.ring != started.ring
|| old.index != started.index) return false;
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;
}
public static List<int[]> ring(int radius) {
if (radius == 0) return List.of(new int[] {0, 0});
var cells = new ArrayList<int[]>();
long outer = (long) radius * radius;
long inner = (long) (radius - 1) * (radius - 1);
for (int x = -radius; x <= radius; x++) {
long square = (long) x * x;
int top = (int) Math.sqrt(outer - square);
int gap = square > inner ? -1 : (int) Math.sqrt(inner - square);
if (gap < 0) {
for (int z = -top; z <= top; z++) cells.add(new int[] {x, z});
} else {
for (int z = -top; z < -gap; z++) cells.add(new int[] {x, z});
for (int z = gap + 1; z <= top; z++) cells.add(new int[] {x, z});
}
}
return cells;
}
public static boolean below(long bytes, double gib) {
return gib > 0 && bytes < gib * (1L << 30);
}
public static long bytes(Path world) throws IOException {
try (var files = Files.walk(world)) {
long total = 0;
var entries = files.iterator();
while (entries.hasNext()) {
var path = entries.next();
if (Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
total += Files.size(path);
}
}
return total;
}
}
}

View file

@ -1,6 +1,16 @@
package dev.quorate.tune;
import dev.quorate.tune.mixin.ModRanges;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod;
@Mod("saltatory")
public final class TuneMod {}
public final class TuneMod {
public TuneMod(ModContainer mod) {
if (ModRanges.loaded("chunky", "[1.4,1.5)")
&& ModRanges.loaded("distanthorizons", "[3.3.3,3.4)")) {
IdleRings.register(mod);
}
}
}

View file

@ -0,0 +1,17 @@
package dev.quorate.tune.mixin;
import com.seibel.distanthorizons.core.wrapperInterfaces.modAccessor.AbstractChunkyAccessor;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(value = AbstractChunkyAccessor.class, remap = false)
public abstract class ChunkyInit {
@Inject(method = "tryRunFirstTimeSetup", at = @At("HEAD"), cancellable = true)
private void saltatory$setup(CallbackInfo ci) {
// DH marks the listener bound before invoking the provider.
if (ChunkyRef.saltatory$get() == null) ci.cancel();
}
}

View file

@ -0,0 +1,14 @@
package dev.quorate.tune.mixin;
import org.popcraft.chunky.Chunky;
import org.popcraft.chunky.ChunkyProvider;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(value = ChunkyProvider.class, remap = false)
public interface ChunkyRef {
@Accessor("instance")
static Chunky saltatory$get() {
throw new AssertionError();
}
}

View file

@ -3,9 +3,13 @@ package dev.quorate.tune.mixin;
public final class LodPlugin extends ModPlugin {
static final String TURBO = "[1.5,1.6)";
static final String DH = "[3.3.3,3.4)";
static final String CHUNKY = "[1.4,1.5)";
@Override
public boolean shouldApplyMixin(String target, String mixin) {
if (mixin.endsWith(".ChunkyInit") || mixin.endsWith(".ChunkyRef")) {
return ModRanges.loaded("chunky", CHUNKY) && ModRanges.loaded("distanthorizons", DH);
}
if (mixin.endsWith(".LodMap") || mixin.endsWith(".LodQueueMixin")) {
return ModRanges.loaded("distanthorizons", "[3.3.3,)");
}

View file

@ -5,6 +5,8 @@
"compatibilityLevel": "JAVA_21",
"plugin": "dev.quorate.tune.mixin.LodPlugin",
"mixins": [
"ChunkyRef",
"ChunkyInit",
"LodMap",
"LodQueueMixin",
"TurboInit",

View file

@ -0,0 +1,91 @@
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.assertTrue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
final class RingsTest {
@Test
void circularShellsCoverEveryCellOnce() {
var covered = new HashSet<String>();
for (int radius = 0; radius <= 20; radius++) {
for (var cell : Rings.ring(radius)) {
long distance = (long) cell[0] * cell[0] + (long) cell[1] * cell[1];
assertTrue(distance <= (long) radius * radius);
assertTrue(radius == 0 || distance > (long) (radius - 1) * (radius - 1));
assertTrue(covered.add(cell[0] + "," + cell[1]));
}
}
for (int x = -20; x <= 20; x++)
for (int z = -20; z <= 20; z++) {
assertEquals(x * x + z * z <= 400, covered.contains(x + "," + z));
}
}
@Test
void locationsUseTheSameGridAcrossZero() {
var rings = new 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);
var first = rings.next(0).orElseThrow();
assertEquals("a", first.owner());
assertTrue(rings.complete(first));
assertEquals("b", rings.next(0).orElseThrow().owner());
rings.complete(rings.next(0).orElseThrow());
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);
var first = rings.next(0).orElseThrow();
rings.visit("a", "world", 1, 1, 20);
assertTrue(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());
}
@Test
void oldLocationsAreNotExpanded() {
var rings = new Rings();
rings.visit("a", "world", 0, 0, 10);
assertTrue(rings.next(11).isEmpty());
}
@Test
void diskBudgetUsesGibAndZeroDisablesGeneration() {
assertFalse(Rings.below(0, 0));
assertTrue(Rings.below((1L << 30) - 1, 1));
assertFalse(Rings.below(1L << 30, 1));
assertFalse(Rings.below(1L << 29, .5));
}
@Test
void diskUsageIncludesWorldFilesAndDoesNotFollowArtifacts(@TempDir Path root) throws Exception {
var world = Files.createDirectory(root.resolve("world"));
Files.write(world.resolve("region.mca"), new byte[100]);
Files.write(world.resolve("lod.sqlite"), new byte[50]);
var artifact = Files.write(root.resolve("config"), new byte[200]);
Files.createSymbolicLink(world.resolve("config"), artifact);
assertEquals(150, Rings.bytes(world));
}
}

View file

@ -7,6 +7,14 @@ import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
import org.junit.jupiter.api.Test;
final class ModRangesTest {
@Test
void chunkySetupAllowsCompatibleUpdates() {
assertFalse(ModRanges.contains(new DefaultArtifactVersion("1.3.99"), LodPlugin.CHUNKY));
assertTrue(ModRanges.contains(new DefaultArtifactVersion("1.4.0"), LodPlugin.CHUNKY));
assertTrue(ModRanges.contains(new DefaultArtifactVersion("1.4.23"), LodPlugin.CHUNKY));
assertFalse(ModRanges.contains(new DefaultArtifactVersion("1.5.0"), LodPlugin.CHUNKY));
}
@Test
void turboRefinementAllowsCompatiblePatches() {
assertFalse(ModRanges.contains(new DefaultArtifactVersion("1.4.0"), LodPlugin.TURBO));