Generate idle Chunky rings and backfill their saved LOD
This commit is contained in:
parent
a3dd8464a1
commit
dc2f9408a1
12 changed files with 778 additions and 2 deletions
|
|
@ -8,7 +8,8 @@
|
||||||
| [Refined Storage 2.0.9](https://modrinth.com/mod/refined-storage/version/lHHiI26k), by Refined Mods | MIT | `PlanRows` and `PlanMixin` detach shared ingredient rows before writing, preserving branch isolation. Modifications dated 2026-10-16. |
|
| [Refined Storage 2.0.9](https://modrinth.com/mod/refined-storage/version/lHHiI26k), by Refined Mods | MIT | `PlanRows` and `PlanMixin` detach shared ingredient rows before writing, preserving branch isolation. Modifications dated 2026-10-16. |
|
||||||
| [Ponder 1.0.82](https://github.com/Creators-of-Create/Ponder), by The Create Team | MIT | `ColorDraw` and `BatchDraw` adapt Catnip template emission. Modifications dated 2026-10-19 fuse buffer writes and omit unused shader attributes. |
|
| [Ponder 1.0.82](https://github.com/Creators-of-Create/Ponder), by The Create Team | MIT | `ColorDraw` and `BatchDraw` adapt Catnip template emission. Modifications dated 2026-10-19 fuse buffer writes and omit unused shader attributes. |
|
||||||
| [Iris 1.8.12](https://github.com/IrisShaders/Iris), by coderbot and IMS212 | LGPL-3.0-only | `BatchDraw` and `QuadEnd` adapt extended quad completion. Modifications dated 2026-10-19 preserve deferred completion while skipping unconsumed attributes. |
|
| [Iris 1.8.12](https://github.com/IrisShaders/Iris), by coderbot and IMS212 | LGPL-3.0-only | `BatchDraw` and `QuadEnd` adapt extended quad completion. Modifications dated 2026-10-19 preserve deferred completion while skipping unconsumed attributes. |
|
||||||
| [Distant Horizons 3.3.3](https://gitlab.com/distant-horizons-team/distant-horizons) | LGPL-3.0 | Runtime hooks cache light emission and ID mappings and replace chunk-priority storage. Modifications dated 2026-11-09. |
|
| [Distant Horizons 3.3.3](https://gitlab.com/distant-horizons-team/distant-horizons) | LGPL-3.0 | Runtime hooks cache light emission and ID mappings, replace chunk-priority storage, defer Chunky listener setup until initialization, and convert saved chunks to LOD after idle generation. Modifications dated 2026-11-12. |
|
||||||
|
| [Chunky 1.4.23](https://github.com/pop4959/Chunky), by pop4959 | GPL-3.0 | Optional runtime dependency. Idle rings start generation tasks and observe their lifecycle through Chunky interfaces. |
|
||||||
| [YUNG's Better Caves 3.1.6](https://github.com/YUNG-GANG/YUNGs-Better-Caves), by YUNG | LGPL-3.0 | `CavePatch`, `CaveRows`, and `CaveFrame` adapt noise interpolation and threshold storage while retaining the upstream carving methods. Modifications dated 2026-11-09. |
|
| [YUNG's Better Caves 3.1.6](https://github.com/YUNG-GANG/YUNGs-Better-Caves), by YUNG | LGPL-3.0 | `CavePatch`, `CaveRows`, and `CaveFrame` adapt noise interpolation and threshold storage while retaining the upstream carving methods. Modifications dated 2026-11-09. |
|
||||||
| [Biolith 3.0.14](https://github.com/TerraformersMC/Biolith), by TerraformersMC and gniftygnome | LGPL-3.0 | `BiomeIndex` adapts nearest-biome search to flat storage; runtime hooks preserve upstream result types and cache history. Modifications dated 2026-11-09. |
|
| [Biolith 3.0.14](https://github.com/TerraformersMC/Biolith), by TerraformersMC and gniftygnome | LGPL-3.0 | `BiomeIndex` adapts nearest-biome search to flat storage; runtime hooks preserve upstream result types and cache history. Modifications dated 2026-11-09. |
|
||||||
| [C2ME](https://github.com/RelativityMC/C2ME-neoforge), by ishland | MIT | Runtime hooks use the density compiler's expression tree, memoize native noise, and index aquifer and structure inputs. Modifications dated 2026-11-09. |
|
| [C2ME](https://github.com/RelativityMC/C2ME-neoforge), by ishland | MIT | Runtime hooks use the density compiler's expression tree, memoize native noise, and index aquifer and structure inputs. Modifications dated 2026-11-09. |
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ dependencies {
|
||||||
compileOnly('curse.maven:functional-storage-556861:8459097') { transitive = false }
|
compileOnly('curse.maven:functional-storage-556861:8459097') { transitive = false }
|
||||||
compileOnly('maven.modrinth:refined-storage:lHHiI26k') { transitive = false }
|
compileOnly('maven.modrinth:refined-storage:lHHiI26k') { transitive = false }
|
||||||
compileOnly('maven.modrinth:distanthorizons:9w34y8ai') { 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('com.pockyl:dhturbo-neoforge-1.21.1:1.5.0') { transitive = false }
|
||||||
compileOnly 'org.jetbrains:annotations:24.1.0'
|
compileOnly 'org.jetbrains:annotations:24.1.0'
|
||||||
compileOnly 'org.apiguardian:apiguardian-api:1.1.2'
|
compileOnly 'org.apiguardian:apiguardian-api:1.1.2'
|
||||||
|
|
|
||||||
392
server/src/main/java/dev/quorate/tune/IdleRings.java
Normal file
392
server/src/main/java/dev/quorate/tune/IdleRings.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
117
server/src/main/java/dev/quorate/tune/LodBatch.java
Normal file
117
server/src/main/java/dev/quorate/tune/LodBatch.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
119
server/src/main/java/dev/quorate/tune/Rings.java
Normal file
119
server/src/main/java/dev/quorate/tune/Rings.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
package dev.quorate.tune;
|
package dev.quorate.tune;
|
||||||
|
|
||||||
|
import dev.quorate.tune.mixin.ModRanges;
|
||||||
|
|
||||||
|
import net.neoforged.fml.ModContainer;
|
||||||
import net.neoforged.fml.common.Mod;
|
import net.neoforged.fml.common.Mod;
|
||||||
|
|
||||||
@Mod("saltatory")
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
17
server/src/main/java/dev/quorate/tune/mixin/ChunkyInit.java
Normal file
17
server/src/main/java/dev/quorate/tune/mixin/ChunkyInit.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
server/src/main/java/dev/quorate/tune/mixin/ChunkyRef.java
Normal file
14
server/src/main/java/dev/quorate/tune/mixin/ChunkyRef.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,13 @@ package dev.quorate.tune.mixin;
|
||||||
public final class LodPlugin extends ModPlugin {
|
public final class LodPlugin extends ModPlugin {
|
||||||
static final String TURBO = "[1.5,1.6)";
|
static final String TURBO = "[1.5,1.6)";
|
||||||
static final String DH = "[3.3.3,3.4)";
|
static final String DH = "[3.3.3,3.4)";
|
||||||
|
static final String CHUNKY = "[1.4,1.5)";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean shouldApplyMixin(String target, String mixin) {
|
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")) {
|
if (mixin.endsWith(".LodMap") || mixin.endsWith(".LodQueueMixin")) {
|
||||||
return ModRanges.loaded("distanthorizons", "[3.3.3,)");
|
return ModRanges.loaded("distanthorizons", "[3.3.3,)");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
"compatibilityLevel": "JAVA_21",
|
"compatibilityLevel": "JAVA_21",
|
||||||
"plugin": "dev.quorate.tune.mixin.LodPlugin",
|
"plugin": "dev.quorate.tune.mixin.LodPlugin",
|
||||||
"mixins": [
|
"mixins": [
|
||||||
|
"ChunkyRef",
|
||||||
|
"ChunkyInit",
|
||||||
"LodMap",
|
"LodMap",
|
||||||
"LodQueueMixin",
|
"LodQueueMixin",
|
||||||
"TurboInit",
|
"TurboInit",
|
||||||
|
|
|
||||||
91
server/src/test/java/dev/quorate/tune/RingsTest.java
Normal file
91
server/src/test/java/dev/quorate/tune/RingsTest.java
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,14 @@ import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
final class ModRangesTest {
|
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
|
@Test
|
||||||
void turboRefinementAllowsCompatiblePatches() {
|
void turboRefinementAllowsCompatiblePatches() {
|
||||||
assertFalse(ModRanges.contains(new DefaultArtifactVersion("1.4.0"), LodPlugin.TURBO));
|
assertFalse(ModRanges.contains(new DefaultArtifactVersion("1.4.0"), LodPlugin.TURBO));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue