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

View file

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

View file

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

View file

@ -53,6 +53,9 @@ public abstract class RefineQueue implements LodWork.Queue {
mapper, mapper,
reducer, reducer,
pos -> DhSectionPos.getDetailLevel(pos) == 6, 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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 static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -32,45 +34,66 @@ final class RingsTest {
@Test @Test
void locationsUseTheSameGridAcrossZero() { void locationsUseTheSameGridAcrossZero() {
var rings = new Rings(); 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()); assertEquals(new Rings.Cell("world", -1, -2), rings.next(0).orElseThrow().cell());
} }
@Test @Test
void nearbyCoveragePrecedesLargerRingsAndPlayersTakeTurns() { void nearbyCoveragePrecedesLargerRingsAndPlayersTakeTurns() {
var rings = new Rings(); var rings = new Rings();
rings.visit("a", "world", 0, 0, 10); rings = rings.visit("a", "world", 0, 0, 10);
rings.visit("b", "world", 90, 90, 10); rings = rings.visit("b", "world", 90, 90, 10);
var first = rings.next(0).orElseThrow(); var first = rings.next(0).orElseThrow();
assertEquals("a", first.owner()); 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()); assertEquals("b", rings.next(0).orElseThrow().owner());
rings.complete(rings.next(0).orElseThrow()); rings = rings.complete(rings.next(0).orElseThrow());
rings.complete(rings.next(0).orElseThrow()); rings = rings.complete(rings.next(0).orElseThrow());
assertEquals("b", rings.next(0).orElseThrow().owner()); assertEquals("b", rings.next(0).orElseThrow().owner());
} }
@Test @Test
void completionRetainsActivityButDoesNotAdvanceAMovedLocation() { void completionRetainsActivityButDoesNotAdvanceAMovedLocation() {
var rings = new Rings(); 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(); var first = rings.next(0).orElseThrow();
rings.visit("a", "world", 1, 1, 20); rings = rings.visit("a", "world", 1, 1, 20);
assertTrue(rings.complete(first)); rings = rings.complete(first);
assertEquals(20, rings.sites.get("a").seen()); assertEquals(20, rings.sites().get("a").seen());
var second = rings.next(0).orElseThrow(); var second = rings.next(0).orElseThrow();
rings.visit("a", "world", 100, 100, 30); rings = rings.visit("a", "world", 100, 100, 30);
assertFalse(rings.complete(second)); assertSame(rings, rings.complete(second));
assertEquals(0, rings.sites.get("a").ring()); assertEquals(0, rings.sites().get("a").ring());
} }
@Test @Test
void oldLocationsAreNotExpanded() { void oldLocationsAreNotExpanded() {
var rings = new Rings(); var rings = new Rings();
rings.visit("a", "world", 0, 0, 10); rings = rings.visit("a", "world", 0, 0, 10);
assertTrue(rings.next(11).isEmpty()); 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 @Test
void diskBudgetUsesGibAndZeroDisablesGeneration() { void diskBudgetUsesGibAndZeroDisablesGeneration() {
assertFalse(Rings.below(0, 0)); assertFalse(Rings.below(0, 0));