diff --git a/app/src/main/java/net/onelitefeather/titan/app/Titan.java b/app/src/main/java/net/onelitefeather/titan/app/Titan.java
index 42a2391..80a56dc 100644
--- a/app/src/main/java/net/onelitefeather/titan/app/Titan.java
+++ b/app/src/main/java/net/onelitefeather/titan/app/Titan.java
@@ -71,7 +71,9 @@ public void initialize() {
}
public void terminate() {
-
+ // The Falco chunk loader keeps region files open for as long as it lives, so the shutdown
+ // is where they are flushed and closed.
+ this.mapProvider.close();
}
private void initCommands() {
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
index 4b83669..6a791c0 100644
--- a/common/build.gradle.kts
+++ b/common/build.gradle.kts
@@ -10,6 +10,18 @@ dependencies {
implementation(libs.togglz)
implementation(libs.aves)
implementation(libs.adventure.minimessage)
+ // Falco replaces Minestom's AnvilLoader and light engine (US-1.01 - US-1.03).
+ // Every falco artifact carries mycelium-bom as a platform dependency, and it is a release
+ // ahead of the one aonyx-bom brings. Highest-wins would move Minestom underneath us with
+ // nothing to announce it, which is what NFR-001 forbids: the Minestom version is the one
+ // aonyx-bom prescribes. Excluded here rather than pinned, so the next falco release cannot
+ // move it either.
+ implementation(libs.falco.anvil) { exclude(group = "net.onelitefeather", module = "mycelium-bom") }
+ implementation(libs.falco.light) { exclude(group = "net.onelitefeather", module = "mycelium-bom") }
+ // Runtime only for us: nothing here names a falco-instance type, but linking
+ // ChunkLightScheduler resolves FalcoLightingChunk and its FalcoChunk supertype. See the
+ // version catalog for the details.
+ runtimeOnly(libs.falco.instance) { exclude(group = "net.onelitefeather", module = "mycelium-bom") }
// No CloudNet here anymore: anything touching the CloudNet bridge lives in the
// :bridge extension; common only talks to it through the JDK-typed
@@ -17,6 +29,9 @@ dependencies {
testImplementation(platform(libs.aonyx.bom))
testImplementation(libs.minestom)
+ testImplementation(libs.falco.anvil) { exclude(group = "net.onelitefeather", module = "mycelium-bom") }
+ testImplementation(libs.falco.light) { exclude(group = "net.onelitefeather", module = "mycelium-bom") }
+ testRuntimeOnly(libs.falco.instance) { exclude(group = "net.onelitefeather", module = "mycelium-bom") }
testImplementation(libs.cyano)
testImplementation(libs.aves)
testImplementation(libs.junit.api)
diff --git a/common/src/main/java/net/onelitefeather/titan/common/map/MapEntry.java b/common/src/main/java/net/onelitefeather/titan/common/map/MapEntry.java
index e8d0090..658e1b5 100644
--- a/common/src/main/java/net/onelitefeather/titan/common/map/MapEntry.java
+++ b/common/src/main/java/net/onelitefeather/titan/common/map/MapEntry.java
@@ -17,18 +17,36 @@
package net.onelitefeather.titan.common.map;
import net.onelitefeather.titan.common.config.AppConfig;
-import org.jetbrains.annotations.NotNull;
import java.nio.file.Files;
import java.nio.file.Path;
-public record MapEntry(@NotNull Path path) {
+/**
+ * The {@link MapEntry} record points at one world directory below {@code worlds} and answers
+ * whether that directory carries map data.
+ *
+ * @param path the root directory of the world
+ * @author theEvilReaper
+ * @version 1.0.0
+ * @since 1.0.0
+ */
+public record MapEntry(Path path) {
+ /**
+ * Checks whether the world directory carries the map data file.
+ *
+ * @return true if the file exists, otherwise false
+ */
public boolean hasMapFile() {
return Files.exists(path.resolve(AppConfig.MAP_FILE_NAME));
}
- public @NotNull Path getMapFile() {
+ /**
+ * Gets the map data file of this world, whether it exists or not.
+ *
+ * @return the path of the map data file
+ */
+ public Path getMapFile() {
return path.resolve(AppConfig.MAP_FILE_NAME);
}
}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/map/MapPool.java b/common/src/main/java/net/onelitefeather/titan/common/map/MapPool.java
index 9f3f766..52131dc 100644
--- a/common/src/main/java/net/onelitefeather/titan/common/map/MapPool.java
+++ b/common/src/main/java/net/onelitefeather/titan/common/map/MapPool.java
@@ -18,7 +18,6 @@
import net.minestom.server.MinecraftServer;
import net.minestom.server.utils.validate.Check;
-import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.UnmodifiableView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -29,24 +28,44 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Optional;
import java.util.function.Function;
+import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* The map pool is responsible for managing the available maps. It will load all
* maps data from the given path and store them. It would not load the map
- * itself over a {@link net.minestom.server.instance.anvil.AnvilLoader}
- * instance. This behavior is handled by another class.
+ * itself over a chunk loader instance. This behavior is handled by another class.
+ *
+ * Which of the maps is the active one is decided by the system property
+ * {@value #LOBBY_MAP_PROPERTY}, and it is decided by that property alone: the amount of worlds
+ * below the map directory does not change how the property is read. A world that is named there but
+ * is not present is a typo and not a reason to refuse the start, so it is reported by name, next to
+ * the names that were actually found, and the pool falls back to {@value #DEFAULT_MAP_NAME}.
+ *
*
* @author theEvilReaper
- * @version 1.0.0
+ * @author TheMeinerLP
+ * @version 1.1.0
* @since 1.0.0
**/
public final class MapPool {
private static final Logger LOGGER = LoggerFactory.getLogger(MapPool.class);
- private static final String LOBBY_MAP_NAME = System.getProperty("TITAN_LOBBY_MAP", "world");
- private List referenceList;
+ /**
+ * The system property that names the world the lobby starts with.
+ */
+ public static final String LOBBY_MAP_PROPERTY = "TITAN_LOBBY_MAP";
+
+ /**
+ * The name of the world the pool falls back to when the named one is absent.
+ */
+ public static final String DEFAULT_MAP_NAME = "world";
+
+ private final String requestedMapName;
+ private boolean requestedMapSelected;
+ private final List referenceList;
private MapEntry selectedMap;
private final Function, List> filterMaps;
@@ -54,22 +73,99 @@ public final class MapPool {
* Creates a new instance of the map pool. It will load all maps from the given
* path.
*
- * @param path
- * the path where the maps are stored
+ * @param path the path where the maps are stored
+ * @param filterMaps the filter that decides which directories count as a world
*/
- public MapPool(@NotNull Path path, @NotNull Function, List> filterMaps) {
+ public MapPool(Path path, Function, List> filterMaps) {
this.filterMaps = filterMaps;
- this.referenceList = loadMapsEntries(path);
+ // Read per instance rather than once per class: a static field would freeze the value at
+ // class load, which is both untestable and a source of surprises when the property is set
+ // from code rather than the command line.
+ this.requestedMapName = System.getProperty(LOBBY_MAP_PROPERTY, DEFAULT_MAP_NAME);
+ // Copied because the filter decides what it returns and one of them hands back an immutable
+ // list, which the pool has to be able to empty.
+ this.referenceList = new ArrayList<>(loadMapsEntries(path));
this.peekMap();
}
+ /**
+ * Selects the world the lobby starts with.
+ *
+ * The property wins whenever the world it names exists. When it does not, the searched name and
+ * the found names are logged together — the two halves that make a typo obvious — and the
+ * default world is used instead. Only a pool without any world at all is fatal, because there
+ * is
+ * then nothing left to start with.
+ *
+ */
private void peekMap() {
Check.argCondition(this.referenceList.isEmpty(), "The map list is empty");
- if (this.referenceList.size() == 1) {
- this.selectedMap = this.referenceList.getFirst();
+
+ Optional requested = findMap(this.requestedMapName);
+ if (requested.isPresent()) {
+ this.selectedMap = requested.get();
+ this.requestedMapSelected = true;
return;
}
- this.selectedMap = this.referenceList.stream().filter(mapEntry -> mapEntry.path().getFileName().toString().equalsIgnoreCase(LOBBY_MAP_NAME)).findFirst().orElseThrow();
+
+ // Neither refusing to start nor picking blindly: the default world first, and if that is
+ // not there either the first world that was found, because leaving the lobby down over a
+ // naming question would be the worse outcome. The warning is written afterwards so that it
+ // can name the world that was actually taken rather than the one the rule prefers.
+ this.selectedMap = findMap(DEFAULT_MAP_NAME).orElseGet(this.referenceList::getFirst);
+ LOGGER.warn(describeMissingMap(this.requestedMapName, availableMapNames(), selectedMapName()));
+ }
+
+ /**
+ * Gets the name of the world directory that was selected.
+ *
+ * @return the name of the selected world
+ */
+ private String selectedMapName() {
+ return this.selectedMap.path().getFileName().toString();
+ }
+
+ /**
+ * Builds the warning that reports a world which the property named but which is not there.
+ *
+ * The message is built rather than formatted into the log call so that the rules behind it —
+ * the searched name, the found ones and the world that was taken instead all have to appear —
+ * are ones a test can hold the code to.
+ *
+ *
+ * The fallback is named rather than assumed. The message used to announce the default world
+ * unconditionally, including in the case where the default world was missing as well and the
+ * code went on with the first world it had found: it said one thing and did another, which is
+ * the one failure mode a warning of this kind must not have.
+ *
+ *
+ * @param requested the name of the world that was searched for
+ * @param found the names of the worlds that are present
+ * @param fallback the name of the world that was selected instead
+ * @return the warning to log
+ */
+ static String describeMissingMap(String requested, List found, String fallback) {
+ String target = DEFAULT_MAP_NAME.equalsIgnoreCase(fallback) ? "the default world '" + fallback + "'" : "the world '" + fallback + "', because the default world '" + DEFAULT_MAP_NAME + "' is not there either";
+ return "The world '" + requested + "' named by the system property " + LOBBY_MAP_PROPERTY + " does not exist. Found worlds: " + String.join(", ", found) + ". Falling back to " + target + ".";
+ }
+
+ /**
+ * Looks up a world by the name of its directory, ignoring case.
+ *
+ * @param name the name of the world directory
+ * @return the entry of that world, or empty when no world carries the name
+ */
+ private Optional findMap(String name) {
+ return this.referenceList.stream().filter(mapEntry -> mapEntry.path().getFileName().toString().equalsIgnoreCase(name)).findFirst();
+ }
+
+ /**
+ * Gets the names of every world the pool found, for a log line that has to name them.
+ *
+ * @return the names of the found worlds
+ */
+ List availableMapNames() {
+ return this.referenceList.stream().map(mapEntry -> mapEntry.path().getFileName().toString()).collect(Collectors.toList());
}
/**
@@ -80,7 +176,7 @@ private void peekMap() {
* the path where the maps are stored
* @return a list with all available maps
*/
- private @NotNull List loadMapsEntries(@NotNull Path path) {
+ private List loadMapsEntries(Path path) {
List mapEntries = new ArrayList<>();
try (Stream stream = Files.list(path)) {
mapEntries = this.filterMaps.apply(stream.filter(Files::isDirectory));
@@ -91,22 +187,49 @@ private void peekMap() {
return mapEntries;
}
+ /**
+ * Gets the name of the world the system property asked for, which is
+ * {@value #DEFAULT_MAP_NAME} when the property is not set.
+ *
+ * @return the name of the requested world
+ */
+ public String getRequestedMapName() {
+ return this.requestedMapName;
+ }
+
+ /**
+ * Answers whether the world the property named is the one that was selected.
+ *
+ * A false here is the fallback case: the named world was not found and the pool went on with
+ * another one rather than refusing to start.
+ *
+ *
+ * @return true if the requested world was found, otherwise false
+ */
+ public boolean isRequestedMapSelected() {
+ return this.requestedMapSelected;
+ }
+
/**
* Gets the selected map entry.
*
* @return the selected map entry
*/
- public @NotNull MapEntry getMapEntry() {
+ public MapEntry getMapEntry() {
return this.selectedMap;
}
/**
- * Removes the selected map from the list. If the list is empty it will throw an
- * exception.
+ * Forgets every world the pool found.
+ *
+ * The list is emptied rather than replaced by null. This package is
+ * {@code @NotNullByDefault}, so a null there was a promise the class broke against itself, and
+ * it turned every later call of {@link #getAvailableMaps()} into a
+ * {@link NullPointerException} instead of an empty list.
+ *
*/
public void clear() {
this.referenceList.clear();
- this.referenceList = null;
}
/**
@@ -114,8 +237,7 @@ public void clear() {
*
* @return an unmodifiable list with all available maps
*/
- public @NotNull
- @UnmodifiableView List getAvailableMaps() {
+ public @UnmodifiableView List getAvailableMaps() {
return Collections.unmodifiableList(this.referenceList);
}
}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java b/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java
index 55eb6ec..8b7bf0f 100644
--- a/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java
+++ b/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java
@@ -20,70 +20,203 @@
import net.minestom.server.coordinate.Point;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
+import net.minestom.server.event.EventListener;
import net.minestom.server.event.instance.InstanceChunkLoadEvent;
+import net.minestom.server.event.instance.InstanceTickEvent;
+import net.minestom.server.event.trait.InstanceEvent;
+import net.minestom.server.instance.ChunkLoader;
import net.minestom.server.instance.Clock;
import net.minestom.server.instance.InstanceContainer;
import net.minestom.server.instance.LightingChunk;
-import net.minestom.server.instance.anvil.AnvilLoader;
+import net.minestom.server.event.EventNode;
import net.minestom.server.utils.chunk.ChunkUtils;
+import net.onelitefeather.falco.anvil.FalcoAnvilLoader;
+import net.onelitefeather.falco.light.ChunkArea;
+import net.onelitefeather.falco.light.ChunkLightScheduler;
+import net.onelitefeather.falco.light.ChunkLightService;
import net.onelitefeather.titan.common.config.AppConfig;
import net.theevilreaper.aves.file.GsonFileHandler;
import net.theevilreaper.aves.file.gson.PositionGsonAdapter;
import net.theevilreaper.aves.map.BaseMap;
-import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.io.IOException;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.NoSuchElementException;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
-public final class MapProvider {
+/**
+ * The {@link MapProvider} class owns the lobby instance: it picks the world the
+ * {@link MapPool} selected, reads the map data next to it and wires the engine that serves the
+ * chunks of that world.
+ *
+ * Chunks are read by Falco's {@link FalcoAnvilLoader} instead of Minestom's {@code AnvilLoader}.
+ * The difference that matters for a built lobby is what happens when a chunk cannot be read: the
+ * Falco loader reports the failure, while the Minestom one reports the chunk as absent, which makes
+ * the server generate a fresh chunk and overwrite the built one on the next save.
+ *
+ *
+ * Light is scheduled, not computed on the spot. A chunk that arrives is only marked
+ * as needing light; a {@link ChunkLightScheduler} collects every mark of a tick, groups the marked
+ * chunks into areas which do not overlap and computes each area off the tick thread. Two properties
+ * follow from that, and both of them are the reason this is not done inline:
+ *
+ *
+ * - A chunk is lit again when its neighbours arrive. Marking a chunk marks the eight
+ * around it as well, so the chunk that loaded first — and was lit with nothing beside it — is
+ * corrected as soon as its neighbour shows up. Lighting a chunk inline cannot do that: the write
+ * goes through {@code Light#set}, which clears the update flag of the section, so neither Falco
+ * nor Minestom would ever look at that chunk again and the border would stay dark for good.
+ * - Two chunks are never lit against each other. The Falco loader loads in parallel, so
+ * several adjacent chunks arriving at once is the normal case rather than a corner one, and
+ * lighting them from two threads reads chunks another thread is writing. The result of that is a
+ * seam, it is never an error, and — again because the update flag is cleared — it is
+ * permanent.
+ *
+ *
+ * The scheduler owns the sky pass too. A section which arrives without a {@code SkyLight} array in
+ * its NBT is not recomputed by Minestom, because a fresh {@code Light} reports itself as
+ * valid, so a lobby that leaves the sky to {@code LightingChunk} is a lobby whose sky light is
+ * whatever the region file happened to carry. {@link LightingChunk} stays the chunk type for what
+ * it does do: sending the light to the players.
+ *
+ *
+ * @author theEvilReaper
+ * @author TheMeinerLP
+ * @version 1.2.0
+ * @since 1.0.0
+ */
+public final class MapProvider implements AutoCloseable {
private static final Logger LOGGER = LoggerFactory.getLogger(MapProvider.class);
private static final String MAP_PATH = "worlds";
private final GsonFileHandler fileHandler;
private final MapPool mapPool;
private final Gson gson;
- private InstanceContainer instance;
+ private final ChunkLightScheduler lightScheduler;
+ private final EventListener chunkLoadListener;
+ private final EventListener tickListener;
+ /**
+ * Counts the light passes. The scheduler only needs a value that differs from the one it saw
+ * last, and the tick event of Minestom carries a duration rather than a timestamp.
+ */
+ private final AtomicLong lightPass = new AtomicLong();
+ private final InstanceContainer instance;
private LobbyMap activeLobby;
+ private @Nullable FalcoAnvilLoader chunkLoader;
+ private @Nullable Path chunkLoaderRoot;
+ private volatile boolean closed;
- private MapProvider(@NotNull Path path, @NotNull InstanceContainer instance, Function, List> filterMaps) {
+ private MapProvider(Path path, InstanceContainer instance, Function, List> filterMaps) {
this.mapPool = new MapPool(path.resolve(MAP_PATH), filterMaps);
this.instance = instance;
- // "Exploration" lighting: relight each chunk as it is loaded so regions
- // light up while players explore into new map sections (anvil chunks
- // otherwise stay dark until a block update triggers a relight).
- this.instance.eventNode().addListener(InstanceChunkLoadEvent.class, event -> LightingChunk.relight(event.getInstance(), List.of(event.getChunk())));
+ // Use LightingChunk so the world is actually lit: it is what sends sky and block light to
+ // the players. Plain DynamicChunks send none, leaving the lobby pitch black. Must be set
+ // before any chunk is loaded by the chunk loader.
+ this.instance.setChunkSupplier(LightingChunk::new);
+ // The scheduler is built per instance, as its contract requires, and it is what keeps the
+ // light of the lobby correct while chunks keep arriving; see the class comment.
+ this.lightScheduler = ChunkLightScheduler.builder(new ChunkLightService()).skyLight(ChunkLightScheduler.SkyLight.FROM_DIMENSION).onAreaCompleted(this::resendLight).build();
+ this.chunkLoadListener = EventListener.of(InstanceChunkLoadEvent.class, this::markLoadedChunk);
+ this.tickListener = EventListener.of(InstanceTickEvent.class, this::runLightPass);
+ this.instance.eventNode().addListener(this.chunkLoadListener);
+ this.instance.eventNode().addListener(this.tickListener);
var typeAdapter = new PositionGsonAdapter();
this.gson = new Gson().newBuilder().registerTypeAdapter(Pos.class, typeAdapter).registerTypeAdapter(Vec.class, typeAdapter).create();
this.fileHandler = new GsonFileHandler(this.gson);
- this.loadMapData();
+ try {
+ this.loadMapData();
+ } catch (RuntimeException | Error failure) {
+ // The chunk loader is installed by loadMapData and holds open region files. A provider
+ // whose constructor threw is never closed by anybody, so it closes itself here.
+ this.close();
+ throw failure;
+ }
}
- private MapProvider(@NotNull Path path, @NotNull InstanceContainer instance) {
+ private MapProvider(Path path, InstanceContainer instance) {
this(path, instance, MapProvider::defaultFilter);
}
+ /**
+ * Marks a chunk that has just been loaded as needing light.
+ *
+ * This runs on the thread that loaded the chunk — a virtual thread, since the Falco loader
+ * loads in parallel — and does no work beyond the mark, which is the point: the light itself is
+ * computed by the scheduler, once per tick, over areas that do not overlap.
+ *
+ *
+ * @param event the event of the chunk that was loaded
+ */
+ private void markLoadedChunk(InstanceChunkLoadEvent event) {
+ if (this.closed) {
+ return;
+ }
+ // markChanged rather than markDirty: nothing is known about which blocks arrived, so the
+ // light kept for the chunk is dropped, and the eight chunks around it are marked as well
+ // because the new blocks reach into them.
+ this.lightScheduler.markChanged(event.getInstance(), event.getChunkX(), event.getChunkZ());
+ }
+
+ /**
+ * Runs the light pass of one tick.
+ *
+ * @param event the tick event of the lobby instance
+ */
+ private void runLightPass(InstanceTickEvent event) {
+ if (this.closed) {
+ return;
+ }
+ this.lightScheduler.onTick(event.getInstance(), this.lightPass.incrementAndGet());
+ }
+
+ /**
+ * Sends the light of the chunks a pass rewrote to the players who already hold them.
+ *
+ * The scheduler drops the cached packets of every chunk it wrote, which covers everybody who
+ * receives the chunk afterwards. It cannot cover a player who is already looking at it, because
+ * a {@link LightingChunk} does not announce its own light; that is what the resend timer of
+ * Minestom is for, and this is where it is armed.
+ *
+ *
+ * @param claimed the chunks the pass took
+ * @param written the chunks it actually wrote
+ */
+ private void resendLight(List claimed, List written) {
+ for (ChunkArea position : written) {
+ if (this.instance.getChunk(position.x(), position.z()) instanceof LightingChunk chunk) {
+ chunk.invalidateResendDelay();
+ }
+ }
+ }
+
private static List defaultFilter(Stream pathStream) {
return pathStream.map(MapEntry::new).filter(MapEntry::hasMapFile).collect(Collectors.toList());
}
- public void saveMap(@NotNull BaseMap baseMap) {
+ /**
+ * Writes the given map next to the world it belongs to and reads it back.
+ *
+ * @param baseMap the map to store
+ * @throws IllegalStateException if the provider is closed
+ */
+ public void saveMap(BaseMap baseMap) {
+ ensureOpen();
this.fileHandler.save(this.mapPool.getMapEntry().path().resolve(AppConfig.MAP_FILE_NAME), baseMap instanceof LobbyMap gameMap ? gameMap : baseMap);
loadMapData();
}
private void loadMapData() {
+ ensureOpen();
var lobbyData = this.fileHandler.load(this.mapPool.getMapEntry().path().resolve(AppConfig.MAP_FILE_NAME), LobbyMap.class);
- // Use LightingChunk so the world is actually lit: it computes and sends
- // sky/block light. Plain DynamicChunks send no light, leaving the lobby
- // pitch black. Must be set before any chunk is loaded by the AnvilLoader.
- this.instance.setChunkSupplier(LightingChunk::new);
// Freeze the lobby at midday so it stays bright; otherwise the default
// day/night cycle keeps advancing and the world renders dark.
this.instance.setTime(6000);
@@ -91,7 +224,7 @@ private void loadMapData() {
if (clock != null) {
clock.rate(0.0f);
}
- this.instance.setChunkLoader(new AnvilLoader(mapPool.getMapEntry().path()));
+ this.installChunkLoader(this.mapPool.getMapEntry().path());
try {
this.activeLobby = lobbyData.orElse(LobbyMap.lobbyMapBuilder().build());
@@ -104,30 +237,163 @@ private void loadMapData() {
}
- private void loadChunk(@NotNull InstanceContainer instance, @NotNull T pos) {
+ /**
+ * Points the instance at the world below the given root, unless it already reads from there.
+ *
+ * The root is the world directory itself and not its {@code region} directory: the Falco loader
+ * resolves {@code dimensions///region} below it and falls back to a plain
+ * {@code region} for a world in the older layout.
+ *
+ *
+ * A loader holds open region files, so the one it replaces is closed here. Reusing the loader
+ * for an unchanged root matters for {@link #saveMap(BaseMap)}, which reads the map data back
+ * and
+ * would otherwise drop every open region file on each call.
+ *
+ *
+ * @param worldRoot the root directory of the world to read
+ */
+ private void installChunkLoader(Path worldRoot) {
+ if (worldRoot.equals(this.chunkLoaderRoot)) {
+ return;
+ }
+ this.closeChunkLoader();
+ FalcoAnvilLoader loader = new FalcoAnvilLoader(worldRoot, this.instance.getDimensionType().key());
+ this.chunkLoader = loader;
+ this.chunkLoaderRoot = worldRoot;
+ this.instance.setChunkLoader(loader);
+ }
+
+ private void closeChunkLoader() {
+ FalcoAnvilLoader loader = this.chunkLoader;
+ this.chunkLoader = null;
+ this.chunkLoaderRoot = null;
+ if (loader == null) {
+ return;
+ }
+ try {
+ loader.close();
+ } catch (IOException exception) {
+ LOGGER.error("Unable to close the chunk loader of the lobby world", exception);
+ }
+ }
+
+ private void ensureOpen() {
+ if (this.closed) {
+ throw new IllegalStateException("The map provider of the lobby world is closed");
+ }
+ }
+
+ /**
+ * Closes the chunk loader and with it every region file the lobby still holds open.
+ *
+ * Unwiring comes before closing, and that order is the whole point of this method. A closed
+ * loader that is still the loader of the instance refuses every chunk a player walks into with
+ * an {@link IllegalStateException}, and the listeners would keep marking chunks for a light
+ * engine nobody is going to drive again. The instance is handed the no-op loader instead, which
+ * reports a chunk as absent rather than throwing — nothing is saved after this point, so
+ * nothing built can be overwritten by it.
+ *
+ *
+ * Closing twice is allowed and does nothing the second time. Everything the provider still
+ * offers after this point is read-only; {@link #saveMap(BaseMap)} refuses, rather than building
+ * a fresh loader and reopening the files that were just closed.
+ *
+ */
+ @Override
+ public void close() {
+ if (this.closed) {
+ return;
+ }
+ this.closed = true;
+ EventNode node = this.instance.eventNode();
+ if (node != null) {
+ node.removeListener(this.chunkLoadListener);
+ node.removeListener(this.tickListener);
+ }
+ this.instance.setChunkLoader(ChunkLoader.noop());
+ this.closeChunkLoader();
+ }
+
+ private void loadChunk(InstanceContainer instance, T pos) {
if (!ChunkUtils.isLoaded(instance, pos)) {
instance.loadChunk(pos);
}
}
+ /**
+ * Gets the instance the lobby world is served from.
+ *
+ * @return the lobby instance
+ */
public InstanceContainer getInstance() {
return instance;
}
- public @NotNull LobbyMap getActiveLobby() {
+ /**
+ * Gets the map data of the world that is currently active.
+ *
+ * @return the active lobby map
+ */
+ public LobbyMap getActiveLobby() {
return activeLobby;
}
- public @NotNull
- @UnmodifiableView List getAvailableMaps() {
+ /**
+ * Answers whether this provider has been closed.
+ *
+ * @return true if the provider is closed, otherwise false
+ */
+ public boolean isClosed() {
+ return this.closed;
+ }
+
+ /**
+ * Answers whether the given chunk is still waiting for its light.
+ *
+ * The light of a chunk is written a tick or more after the chunk itself arrives, so a test that
+ * reads a light level has to know when to look. Nothing in production asks this.
+ *
+ *
+ * @param chunkX the chunk x coordinate
+ * @param chunkZ the chunk z coordinate
+ * @return true if the chunk is still marked as needing light, otherwise false
+ */
+ @ApiStatus.Internal
+ public boolean isLightPending(int chunkX, int chunkZ) {
+ return this.lightScheduler.isDirty(chunkX, chunkZ);
+ }
+
+ /**
+ * Gets every world the pool found below {@code worlds}.
+ *
+ * @return an unmodifiable list with all available maps
+ */
+ public @UnmodifiableView List getAvailableMaps() {
return Collections.unmodifiableList(this.mapPool.getAvailableMaps());
}
- public static MapProvider create(@NotNull Path path, @NotNull InstanceContainer instance) {
+ /**
+ * Creates a provider that reads its worlds below the given path.
+ *
+ * @param path the directory that holds the {@code worlds} directory
+ * @param instance the instance the lobby world is served from
+ * @return a new provider
+ */
+ public static MapProvider create(Path path, InstanceContainer instance) {
return new MapProvider(path, instance);
}
- public static MapProvider create(@NotNull Path path, @NotNull InstanceContainer instance, Function, List> filterMaps) {
+ /**
+ * Creates a provider that reads its worlds below the given path and keeps the entries the given
+ * filter accepts.
+ *
+ * @param path the directory that holds the {@code worlds} directory
+ * @param instance the instance the lobby world is served from
+ * @param filterMaps the filter that decides which directories count as a world
+ * @return a new provider
+ */
+ public static MapProvider create(Path path, InstanceContainer instance, Function, List> filterMaps) {
return new MapProvider(path, instance, filterMaps);
}
}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/map/package-info.java b/common/src/main/java/net/onelitefeather/titan/common/map/package-info.java
new file mode 100644
index 0000000..5f3b837
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/map/package-info.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+/**
+ * Holds the lobby worlds: which one is selected, where its map data lives and which engine serves
+ * its chunks.
+ *
+ * @author theEvilReaper
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+@NotNullByDefault
+package net.onelitefeather.titan.common.map;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/common/src/test/java/net/onelitefeather/titan/common/map/MapPoolTest.java b/common/src/test/java/net/onelitefeather/titan/common/map/MapPoolTest.java
new file mode 100644
index 0000000..ad442d7
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/map/MapPoolTest.java
@@ -0,0 +1,194 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.map;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MapPoolTest {
+
+ private static List allDirectories(Stream paths) {
+ return paths.map(MapEntry::new).collect(Collectors.toList());
+ }
+
+ private static Path worlds(Path root, String... names) throws IOException {
+ Path worlds = Files.createDirectories(root.resolve("worlds"));
+ for (String name : names) {
+ Files.createDirectories(worlds.resolve(name));
+ }
+ return worlds;
+ }
+
+ private static String selectedName(Path worlds) {
+ return new MapPool(worlds, MapPoolTest::allDirectories).getMapEntry().path().getFileName().toString();
+ }
+
+ @AfterEach
+ void clearProperty() {
+ System.clearProperty(MapPool.LOBBY_MAP_PROPERTY);
+ }
+
+ @Test
+ @DisplayName("The property selects the named world when several worlds exist")
+ void testThePropertySelectsTheNamedWorld(@TempDir Path root) throws IOException {
+ Path worlds = worlds(root, "world", "halloween", "winter");
+ System.setProperty(MapPool.LOBBY_MAP_PROPERTY, "halloween");
+
+ assertEquals("halloween", selectedName(worlds));
+ }
+
+ @Test
+ @DisplayName("The property is evaluated even when only one world exists (US-1.05)")
+ void testThePropertyIsEvaluatedForASingleWorld(@TempDir Path root) throws IOException {
+ // The old implementation short-circuited here and took the only entry without ever looking
+ // at the property, which made a single-world machine behave differently from production.
+ Path worlds = worlds(root, "halloween");
+ System.setProperty(MapPool.LOBBY_MAP_PROPERTY, "halloween");
+ MapPool pool = new MapPool(worlds, MapPoolTest::allDirectories);
+
+ assertEquals("halloween", pool.getMapEntry().path().getFileName().toString());
+ assertTrue(pool.isRequestedMapSelected(), "the only world is the requested one");
+ }
+
+ @Test
+ @DisplayName("A single world that the property does not name is reported as a fallback (US-1.05)")
+ void testASingleUnrequestedWorldIsAFallback(@TempDir Path root) throws IOException {
+ // Same selection as before the fix, but no longer a silent one: the property was read and
+ // the world it named was not there.
+ Path worlds = worlds(root, "winter");
+ System.setProperty(MapPool.LOBBY_MAP_PROPERTY, "halloween");
+ MapPool pool = new MapPool(worlds, MapPoolTest::allDirectories);
+
+ assertEquals("winter", pool.getMapEntry().path().getFileName().toString());
+ assertEquals("halloween", pool.getRequestedMapName());
+ assertFalse(pool.isRequestedMapSelected(), "the requested world does not exist");
+ }
+
+ @Test
+ @DisplayName("A missing named world falls back to the default world instead of failing (US-1.04)")
+ void testAMissingNamedWorldFallsBackToTheDefaultWorld(@TempDir Path root) throws IOException {
+ Path worlds = worlds(root, "world", "winter");
+ System.setProperty(MapPool.LOBBY_MAP_PROPERTY, "halloewen");
+
+ assertEquals(MapPool.DEFAULT_MAP_NAME, selectedName(worlds));
+ }
+
+ @Test
+ @DisplayName("A missing named world falls back even when the default world is the only one")
+ void testAMissingNamedWorldFallsBackWithASingleWorld(@TempDir Path root) throws IOException {
+ Path worlds = worlds(root, "world");
+ System.setProperty(MapPool.LOBBY_MAP_PROPERTY, "halloween");
+
+ assertEquals(MapPool.DEFAULT_MAP_NAME, selectedName(worlds));
+ }
+
+ @Test
+ @DisplayName("Without the property the default world is selected")
+ void testWithoutThePropertyTheDefaultWorldIsSelected(@TempDir Path root) throws IOException {
+ Path worlds = worlds(root, "winter", "world");
+
+ assertEquals(MapPool.DEFAULT_MAP_NAME, selectedName(worlds));
+ }
+
+ @Test
+ @DisplayName("The world name is matched ignoring case")
+ void testTheWorldNameIsMatchedIgnoringCase(@TempDir Path root) throws IOException {
+ Path worlds = worlds(root, "world", "Halloween");
+ System.setProperty(MapPool.LOBBY_MAP_PROPERTY, "halloween");
+
+ assertEquals("Halloween", selectedName(worlds));
+ }
+
+ @Test
+ @DisplayName("Without the named and the default world the first found world is used")
+ void testWithoutTheNamedAndTheDefaultWorldTheFirstWorldIsUsed(@TempDir Path root) throws IOException {
+ Path worlds = worlds(root, "winter");
+ System.setProperty(MapPool.LOBBY_MAP_PROPERTY, "halloween");
+
+ assertEquals("winter", selectedName(worlds));
+ }
+
+ @Test
+ @DisplayName("An empty map directory is still fatal")
+ void testAnEmptyMapDirectoryIsFatal(@TempDir Path root) throws IOException {
+ Path worlds = worlds(root);
+
+ assertThrows(IllegalArgumentException.class, () -> selectedName(worlds));
+ }
+
+ @Test
+ @DisplayName("The pool reports every world it found")
+ void testThePoolReportsEveryWorldItFound(@TempDir Path root) throws IOException {
+ Path worlds = worlds(root, "world", "halloween");
+ MapPool pool = new MapPool(worlds, MapPoolTest::allDirectories);
+
+ assertEquals(2, pool.getAvailableMaps().size());
+ assertTrue(pool.availableMapNames().containsAll(List.of("world", "halloween")));
+ }
+
+ @Test
+ @DisplayName("The warning names the searched world and the found ones (US-1.04)")
+ void testTheWarningNamesTheSearchedAndTheFoundWorlds() {
+ String message = MapPool.describeMissingMap("halloewen", List.of("world", "winter"), MapPool.DEFAULT_MAP_NAME);
+
+ assertTrue(message.contains("halloewen"), message);
+ assertTrue(message.contains("world"), message);
+ assertTrue(message.contains("winter"), message);
+ assertTrue(message.contains(MapPool.LOBBY_MAP_PROPERTY), message);
+ assertTrue(message.contains("the default world 'world'"), message);
+ }
+
+ @Test
+ @DisplayName("The warning names the world that was really taken, not the default one")
+ void testTheWarningNamesTheWorldThatWasReallyTaken() {
+ // The message used to announce the default world in this case as well, while the code went
+ // on with the first world it found. Saying one thing and doing another is the one thing a
+ // warning like this must not do.
+ String message = MapPool.describeMissingMap("halloween", List.of("winter"), "winter");
+
+ assertTrue(message.contains("the world 'winter'"), message);
+ assertFalse(message.contains("Falling back to the default world"), message);
+ }
+
+ @Test
+ @DisplayName("Clearing the pool leaves an empty list rather than none")
+ void testClearingThePoolLeavesAnEmptyList(@TempDir Path root) throws IOException {
+ // The package is @NotNullByDefault and the field used to be set to null here, which turned
+ // every later read of the pool into a NullPointerException.
+ Path worlds = worlds(root, "world", "winter");
+ MapPool pool = new MapPool(worlds, MapPoolTest::allDirectories);
+
+ pool.clear();
+
+ assertTrue(pool.getAvailableMaps().isEmpty());
+ assertTrue(pool.availableMapNames().isEmpty());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/map/MapProviderIntegrationTest.java b/common/src/test/java/net/onelitefeather/titan/common/map/MapProviderIntegrationTest.java
new file mode 100644
index 0000000..451e6dc
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/map/MapProviderIntegrationTest.java
@@ -0,0 +1,149 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.map;
+
+import net.minestom.server.instance.ChunkLoader;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.instance.InstanceContainer;
+import net.minestom.testing.Env;
+import net.minestom.testing.extension.MicrotusExtension;
+import net.onelitefeather.falco.anvil.FalcoAnvilLoader;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@ExtendWith(MicrotusExtension.class)
+class MapProviderIntegrationTest {
+
+ private static List allDirectories(Stream paths) {
+ return paths.map(MapEntry::new).collect(Collectors.toList());
+ }
+
+ @AfterEach
+ void clearProperty() {
+ System.clearProperty(MapPool.LOBBY_MAP_PROPERTY);
+ }
+
+ private static MapProvider provider(Env env, Path root, String world) throws IOException {
+ // A world in the layout the lobby worlds actually use: one directory per season below
+ // worlds/, with the region files directly inside it (US-1.06).
+ Files.createDirectories(root.resolve("worlds").resolve(world).resolve("region"));
+ Instance instance = env.createEmptyInstance();
+ return MapProvider.create(root, (InstanceContainer) instance, MapProviderIntegrationTest::allDirectories);
+ }
+
+ @Test
+ @DisplayName("The provider serves the world through Falco's Anvil loader (US-1.01)")
+ void testTheProviderInstallsTheFalcoLoader(Env env, @TempDir Path root) throws IOException {
+ try (MapProvider provider = provider(env, root, "world")) {
+ InstanceContainer instance = provider.getInstance();
+
+ FalcoAnvilLoader loader = assertInstanceOf(FalcoAnvilLoader.class, instance.getChunkLoader());
+ // The loader is handed the world root and resolves the region directory itself. A world
+ // without a dimensions/ directory keeps the older layout, which is the one the lobby
+ // worlds are in.
+ assertEquals(root.resolve("worlds").resolve("world").resolve("region"), loader.regionDirectory(), "the loader resolves the region directory below the world root");
+ assertTrue(loader.legacyLayout(), "a world without a dimensions directory keeps the older layout");
+ }
+ }
+
+ // What the lobby does about light is asserted in MapProviderLightingTest, by loading chunks out
+ // of a region file and reading light levels back. The test that used to stand here asked the
+ // chunk supplier for a chunk and checked its type, which every one of the light defects passed.
+
+ @Test
+ @DisplayName("Closing the provider closes the chunk loader")
+ void testClosingTheProviderClosesTheLoader(Env env, @TempDir Path root) throws IOException {
+ MapProvider provider = provider(env, root, "world");
+ FalcoAnvilLoader loader = (FalcoAnvilLoader) provider.getInstance().getChunkLoader();
+
+ provider.close();
+
+ assertThrows(IllegalStateException.class, () -> loader.loadChunk(provider.getInstance(), 0, 0), "a closed loader refuses further work instead of reporting the chunk as absent");
+ }
+
+ @Test
+ @DisplayName("Closing the provider takes the closed loader off the instance")
+ void testClosingTheProviderUnwiresTheLoader(Env env, @TempDir Path root) throws IOException {
+ MapProvider provider = provider(env, root, "world");
+ InstanceContainer instance = provider.getInstance();
+ ChunkLoader closed = instance.getChunkLoader();
+
+ provider.close();
+
+ assertNotSame(closed, instance.getChunkLoader(), "the closed loader is not the loader of the instance any more");
+ // A player who is still walking around while the server shuts down must not fall over the
+ // IllegalStateException of a loader that was closed underneath the instance.
+ assertDoesNotThrow(() -> instance.loadChunk(0, 0).join(), "a chunk request after the shutdown is answered rather than thrown at");
+ }
+
+ @Test
+ @DisplayName("A closed provider refuses to save instead of reopening the world")
+ void testAClosedProviderRefusesToSave(Env env, @TempDir Path root) throws IOException {
+ MapProvider provider = provider(env, root, "world");
+ ChunkLoader closed = provider.getInstance().getChunkLoader();
+
+ provider.close();
+
+ // Saving reads the map data back, which used to reinstall a chunk loader: the same-root
+ // check compares against a field that close() had just nulled, so it built a fresh loader
+ // and reopened the region files the shutdown had closed.
+ assertThrows(IllegalStateException.class, () -> provider.saveMap(LobbyMap.lobbyMapBuilder().name("late").build()));
+ assertNotSame(closed, provider.getInstance().getChunkLoader(), "no new loader was installed");
+ assertSame(ChunkLoader.noop(), provider.getInstance().getChunkLoader(), "the instance keeps the no-op loader the shutdown left it with");
+ }
+
+ @Test
+ @DisplayName("A closed provider stops asking for light")
+ void testAClosedProviderStopsSchedulingLight(Env env, @TempDir Path root) throws IOException {
+ MapProvider provider = provider(env, root, "world");
+ provider.close();
+
+ provider.getInstance().loadChunk(0, 0).join();
+
+ assertFalse(provider.isLightPending(0, 0), "the chunk load listener is gone with the provider");
+ }
+
+ @Test
+ @DisplayName("Closing twice is harmless")
+ void testClosingTwiceIsHarmless(Env env, @TempDir Path root) throws IOException {
+ MapProvider provider = provider(env, root, "world");
+
+ provider.close();
+
+ assertDoesNotThrow(provider::close);
+ assertTrue(provider.isClosed());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/map/MapProviderLightingTest.java b/common/src/test/java/net/onelitefeather/titan/common/map/MapProviderLightingTest.java
new file mode 100644
index 0000000..1cb810b
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/map/MapProviderLightingTest.java
@@ -0,0 +1,249 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.map;
+
+import net.minestom.server.coordinate.CoordConversion;
+import net.minestom.server.instance.Chunk;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.instance.InstanceContainer;
+import net.minestom.server.instance.block.Block;
+import net.minestom.server.instance.generator.Generator;
+import net.minestom.testing.Env;
+import net.minestom.testing.extension.MicrotusExtension;
+import net.onelitefeather.falco.anvil.FalcoAnvilLoader;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Holds the lobby to the light it promises.
+ *
+ * Every test here reads a real light level out of a chunk the provider loaded from a real region
+ * file. That is the point: a chunk which comes from the loader is never handed to
+ * {@code Chunk#onGenerate()}, so Minestom does not invalidate its sections on the way in, and a
+ * light bug that only shows up on that path is invisible to any test which generates its world.
+ *
+ */
+@ExtendWith(MicrotusExtension.class)
+class MapProviderLightingTest {
+
+ /**
+ * The largest amount of time a light pass may take before the test gives up on it.
+ */
+ private static final Duration SETTLE = Duration.ofSeconds(15);
+
+ /**
+ * The height of the stone floor every test world carries.
+ */
+ private static final int FLOOR_TOP = 1;
+
+ /**
+ * The block coordinates of the lamp which stands in the chunk east of the first one.
+ */
+ private static final int LAMP_X = 16;
+ private static final int LAMP_Y = 5;
+ private static final int LAMP_Z = 8;
+
+ /**
+ * The block coordinates of the lamp which stands in the far corner of the western chunk, out of
+ * reach of the border.
+ */
+ private static final int CORNER_LAMP_X = 0;
+ private static final int CORNER_LAMP_Z = 0;
+
+ private static List allDirectories(Stream paths) {
+ return paths.map(MapEntry::new).collect(Collectors.toList());
+ }
+
+ @AfterEach
+ void clearProperty() {
+ System.clearProperty(MapPool.LOBBY_MAP_PROPERTY);
+ }
+
+ /**
+ * Writes a world to disk and returns the root of it.
+ *
+ * The chunks are generated in a throwaway instance whose chunks are plain {@code DynamicChunk}s
+ * and are then written through a Falco loader. Nothing computes light on the way, so the
+ * sections of the region file carry neither a {@code BlockLight} nor a {@code SkyLight} array —
+ * which is exactly the state a world built in a world editor arrives in.
+ *
+ *
+ * @param env the test environment which owns the throwaway instance
+ * @param root the directory which holds the {@code worlds} directory
+ * @param world the name of the world directory
+ * @param generator the generator which shapes the written chunks
+ * @param chunks the chunks to write, as pairs of chunk coordinates
+ * @return the root directory of the written world
+ */
+ private static Path writeWorld(Env env, Path root, String world, Generator generator, int[][] chunks) throws IOException {
+ Path worldRoot = root.resolve("worlds").resolve(world);
+ Files.createDirectories(worldRoot.resolve("region"));
+
+ Instance source = env.createEmptyInstance();
+ source.setGenerator(generator);
+ List written = new ArrayList<>(chunks.length);
+ for (int[] position : chunks) {
+ written.add(source.loadChunk(position[0], position[1]).join());
+ }
+ try (FalcoAnvilLoader writer = new FalcoAnvilLoader(worldRoot, source.getDimensionType().key())) {
+ for (Chunk chunk : written) {
+ writer.saveChunk(chunk);
+ }
+ }
+ env.destroyInstance(source);
+ return worldRoot;
+ }
+
+ /**
+ * Builds the shape every test world uses: a stone floor and two glowstone blocks.
+ *
+ * The first lamp sits in the far corner of the western chunk and is what tells a test that the
+ * light of that chunk has been written at all. It is far enough from the border — more than
+ * fifteen blocks of travel — that it contributes nothing there, so the level at the border is a
+ * statement about the second lamp alone, which stands one block east of that border in the
+ * chunk next door.
+ *
+ *
+ * @return the generator of the test world
+ */
+ private static Generator floorWithALampOnEachSideOfTheBorder() {
+ return unit -> {
+ unit.modifier().fillHeight(0, FLOOR_TOP, Block.STONE);
+ int startX = unit.absoluteStart().blockX();
+ int startZ = unit.absoluteStart().blockZ();
+
+ if (startX == 0 && startZ == 0) {
+ unit.modifier().setBlock(CORNER_LAMP_X, LAMP_Y, CORNER_LAMP_Z, Block.GLOWSTONE);
+ }
+ if (startX == LAMP_X && startZ == 0) {
+ unit.modifier().setBlock(LAMP_X, LAMP_Y, LAMP_Z, Block.GLOWSTONE);
+ }
+ };
+ }
+
+ private static MapProvider provider(Env env, Path root) {
+ Instance instance = env.createEmptyInstance();
+ return MapProvider.create(root, (InstanceContainer) instance, MapProviderLightingTest::allDirectories);
+ }
+
+ private static int blockLightAt(Instance instance, int x, int y, int z) {
+ return sectionOf(instance, x, y, z).blockLight().getLevel(x & 15, y & 15, z & 15);
+ }
+
+ private static int skyLightAt(Instance instance, int x, int y, int z) {
+ return sectionOf(instance, x, y, z).skyLight().getLevel(x & 15, y & 15, z & 15);
+ }
+
+ private static net.minestom.server.instance.Section sectionOf(Instance instance, int x, int y, int z) {
+ Chunk chunk = instance.getChunk(CoordConversion.globalToChunk(x), CoordConversion.globalToChunk(z));
+ assertTrue(chunk != null, "the chunk holding " + x + "/" + y + "/" + z + " is loaded");
+ return chunk.getSection(CoordConversion.globalToChunk(y));
+ }
+
+ @Test
+ @DisplayName("A chunk that was lit before its neighbour arrived is lit again (D1)")
+ void testAChunkIsRelitWhenItsNeighbourArrives(Env env, @TempDir Path root) throws IOException {
+ writeWorld(env, root, "world", floorWithALampOnEachSideOfTheBorder(), new int[][]{{0, 0}, {1, 0}});
+
+ try (MapProvider provider = provider(env, root)) {
+ InstanceContainer instance = provider.getInstance();
+
+ // The western chunk arrives alone, with nothing east of it. Its corner lamp is what
+ // makes "the light of this chunk has been written" observable at all.
+ instance.loadChunk(0, 0).join();
+ assertTrue(env.tickWhile(() -> blockLightAt(instance, CORNER_LAMP_X + 1, LAMP_Y, CORNER_LAMP_Z) == 0, SETTLE), "the chunk is lit from its own lamp");
+ assertNull(instance.getChunk(1, 0), "the chunk east of the border is not loaded yet");
+ assertEquals(0, blockLightAt(instance, LAMP_X - 1, LAMP_Y, LAMP_Z), "the border is dark while the chunk behind it is missing");
+
+ // The eastern chunk arrives afterwards. Its lamp stands one block across the border, so
+ // a level has to appear on the western side — which happens only if the chunk that was
+ // already lit is lit a second time.
+ instance.loadChunk(1, 0).join();
+ env.tickWhile(() -> blockLightAt(instance, LAMP_X - 1, LAMP_Y, LAMP_Z) == 0, SETTLE);
+
+ assertEquals(14, blockLightAt(instance, LAMP_X - 1, LAMP_Y, LAMP_Z), "the lamp of the chunk that arrived later reaches across the border");
+ }
+ }
+
+ @Test
+ @DisplayName("A chunk whose region file carries no sky light still gets some (D3)")
+ void testSkyLightIsComputedForAWorldWithout(Env env, @TempDir Path root) throws IOException {
+ Path worldRoot = writeWorld(env, root, "world", floorWithALampOnEachSideOfTheBorder(), new int[][]{{0, 0}});
+
+ // First the premise of the test: the world on disk really does carry no sky light, so a
+ // lobby which only reads it and never computes any is a lobby in the dark.
+ Instance bare = env.createEmptyInstance();
+ try (FalcoAnvilLoader reader = new FalcoAnvilLoader(worldRoot, bare.getDimensionType().key())) {
+ Chunk raw = reader.loadChunk(bare, 0, 0);
+ assertTrue(raw != null, "the written chunk is readable");
+ assertEquals(0, raw.getSection(0).skyLight().getLevel(8, 10, 8), "the region file carries no sky light for the section above the floor");
+ }
+ env.destroyInstance(bare);
+
+ try (MapProvider provider = provider(env, root)) {
+ InstanceContainer instance = provider.getInstance();
+ instance.loadChunk(0, 0).join();
+
+ assertTrue(env.tickWhile(() -> skyLightAt(instance, 8, 10, 8) == 0, SETTLE), "the lobby computes the sky light the region file does not carry");
+ assertEquals(15, skyLightAt(instance, 8, 10, 8), "open sky is fully lit");
+ assertEquals(0, skyLightAt(instance, 8, 0, 8), "the block below the floor sees no sky");
+ }
+ }
+
+ @Test
+ @DisplayName("Chunks that arrive together are lit without a seam between them (D2)")
+ void testChunksThatArriveTogetherHaveNoSeam(Env env, @TempDir Path root) throws IOException {
+ int[][] area = {{0, 0}, {1, 0}, {0, 1}, {1, 1}};
+ writeWorld(env, root, "world", floorWithALampOnEachSideOfTheBorder(), area);
+
+ try (MapProvider provider = provider(env, root)) {
+ InstanceContainer instance = provider.getInstance();
+
+ // Falco's loader loads in parallel, so this is the normal case rather than a contrived
+ // one: four adjacent chunks, four virtual threads, overlapping neighbourhoods.
+ List> loading = new ArrayList<>(area.length);
+ for (int[] position : area) {
+ loading.add(instance.loadChunk(position[0], position[1]));
+ }
+ loading.forEach(CompletableFuture::join);
+
+ env.tickWhile(() -> blockLightAt(instance, LAMP_X - 1, LAMP_Y, LAMP_Z) == 0 || blockLightAt(instance, LAMP_X + 1, LAMP_Y, LAMP_Z) == 0, SETTLE);
+
+ int inside = blockLightAt(instance, LAMP_X + 1, LAMP_Y, LAMP_Z);
+ int across = blockLightAt(instance, LAMP_X - 1, LAMP_Y, LAMP_Z);
+ assertEquals(14, inside, "the chunk that carries the lamp is lit by it");
+ assertEquals(inside, across, "the border is not a seam: both sides of the lamp are equally bright");
+ }
+ }
+}
diff --git a/docs/exploration-lighting.md b/docs/exploration-lighting.md
index 1b82831..19ce4bd 100644
--- a/docs/exploration-lighting.md
+++ b/docs/exploration-lighting.md
@@ -14,9 +14,9 @@ There are two distinct behaviours, and they must not be confused:
1. **Lobby = complete relight (now).** The whole lobby is lit. Maps are large
(~18–20k populated chunks each), so we do **not** pre-load and relight every
- chunk at boot. Instead `MapProvider` relights each chunk as it is loaded, so
- everything is lit by the time it is visible — no dark areas, no visible
- reveal.
+ chunk at boot. Instead `MapProvider` schedules the light of each chunk as it
+ is loaded, so everything is lit within a tick or two of becoming visible — no
+ dark areas, no visible reveal.
2. **Special maps = deliberate darkening + per-player reveal (later).** Some
maps should be **deliberately dark** and only light up per player as they
explore (fog-of-war). This reuses the "anvil chunk stays dark until relit"
@@ -24,14 +24,43 @@ There are two distinct behaviours, and they must not be confused:
## Current state (lobby, instance-level)
-`MapProvider` does an **instance-wide** relight:
+`MapProvider` lights the world through Falco's `ChunkLightScheduler`:
- The world instance uses `LightingChunk` (`instance.setChunkSupplier(LightingChunk::new)`).
-- An `InstanceChunkLoadEvent` listener relights every chunk as it is loaded
- (`LightingChunk.relight(instance, List.of(chunk))`), because anvil chunks
- otherwise stay dark until a block update triggers a relight.
-- Lobby time is frozen at midday (`setTime(6000)` + `setTimeRate(0)`) so lit
- chunks render bright.
+- An `InstanceChunkLoadEvent` listener **marks** every chunk that arrives
+ (`ChunkLightScheduler#markChanged`), together with the eight chunks around it.
+ It computes nothing itself. Anvil chunks otherwise stay dark until a block
+ update triggers a relight.
+- An `InstanceTickEvent` listener runs the pass (`ChunkLightScheduler#onTick`).
+ The scheduler collects the marks of a tick, groups them into areas that do not
+ overlap, and computes each area on a virtual thread. Both halves of that
+ matter:
+ - **The marks reach the neighbours**, so the chunk that loaded first — lit
+ with nothing beside it — is lit again when its neighbour turns up. Lighting
+ a chunk once, inline, cannot do that: the write goes through `Light#set`,
+ which clears the update flag of the section, so nothing would ever look at
+ that chunk again and the border would stay dark permanently.
+ - **The areas do not overlap**, so no two threads light chunks against each
+ other. `FalcoAnvilLoader#supportsParallelLoading()` is `true`, which makes
+ several adjacent chunks arriving at once the normal case rather than a
+ corner one, and Falco's own javadoc is explicit that lighting overlapping
+ neighbourhoods concurrently produces a seam — never an error, and permanent
+ for the same reason as above.
+- **The scheduler owns the sky pass too** (`SkyLight.FROM_DIMENSION`, so it runs
+ in a dimension that has sky light and not in one that does not). This is not
+ optional: a fresh Minestom `Light` reports itself as *valid*
+ (`isValidBorders = true`), and `LightingChunk` only relights a section whose
+ `requiresUpdate()` is true — so a section whose region file carries no
+ `SkyLight` array would stay at level 0 for the lifetime of the server.
+ Reading `SkyLight` out of the NBT sets the same flag, so the region file is
+ not a fallback either.
+- `LightingChunk` stays the chunk type for the one thing it does here: sending
+ the light to the players. The scheduler drops the cached packets of every
+ chunk it wrote, which covers whoever receives the chunk next; the resend timer
+ for players who already hold it is armed from the completion callback of the
+ pass.
+- Lobby time is frozen at midday (`setTime(6000)` + `defaultClock().rate(0)`) so
+ lit chunks render bright.
This makes the lobby fully lit (mode 1). The lighting is **shared**: a chunk is
lit once for everyone. It is also the foundation for mode 2 — the per-player
@@ -71,9 +100,9 @@ per-player we override the light **on the wire**, per player:
chunk, mark it discovered and send that player an `UpdateLightPacket` with
the chunk's real (relit) light. Optionally animate the reveal by revealing
neighbouring chunks outward.
-4. **Real light source.** The instance still computes correct light via
- `LightingChunk` (the global relight above); the per-player layer only chooses
- whether to send the real light or a dark override to each player.
+4. **Real light source.** The instance still computes correct light through the
+ Falco scheduler (see above); the per-player layer only chooses whether to
+ send the real light or a dark override to each player.
### Open questions / challenges
@@ -88,10 +117,9 @@ per-player we override the light **on the wire**, per player:
the "dark" light payload and only compute real light once (shared) + resend.
- **Persistence:** decide whether discovered regions persist across sessions
(per-player save) or reset on rejoin.
-- **Cross-version time API:** time is frozen with the legacy
- `setTime`/`setTimeRate`; newer Minestom replaces this with the world
- `Clock` API (`instance.defaultClock().rate(0f)`) — migrate when Minestom is
- bumped.
+- **Cross-version time API:** the rate is already frozen through the world
+ `Clock` API (`instance.defaultClock().rate(0f)`); the time itself is still set
+ through the legacy `Instance#setTime`.
## Next steps
@@ -102,4 +130,5 @@ per-player we override the light **on the wire**, per player:
3. Add per-player discovered-chunk tracking on `TitanPlayer`.
4. Implement the dark-light override on chunk send + reveal `UpdateLightPacket`
on discovery (only for maps with the dark/reveal flag set).
-5. Keep the instance-level `LightingChunk` relight as the real-light source.
+5. Keep the instance-level lighting (Falco block **and** sky light,
+ `LightingChunk` for sending) as the real-light source.
diff --git a/docs/lobby-world-selection.md b/docs/lobby-world-selection.md
new file mode 100644
index 0000000..7cdc9f8
--- /dev/null
+++ b/docs/lobby-world-selection.md
@@ -0,0 +1,111 @@
+# Lobby worlds: layout and selection
+
+How the lobby decides which world it serves, and what a world directory has to
+look like for it to be servable. Covers US-1.01 to US-1.06 of
+[`spec-lobby-saison-events.md`](spec-lobby-saison-events.md).
+
+## One directory per world (US-1.06)
+
+Every world is a directory below `worlds/`, next to the process:
+
+```
+worlds/
+ world/ <- the default world, used when nothing else is selected
+ halloween/
+ winter/
+```
+
+The name of the directory is the name of the world. Nothing in the code knows
+these names — adding a season means adding a directory and setting one property,
+never a code change.
+
+A directory only counts as a world if it carries the map data file
+(`AppConfig.MAP_FILE_NAME`, `map.json`) — that is what `:app` filters on. The
+`:setup` module accepts every directory, because that is where a new world gets
+its map data in the first place.
+
+Inside the directory, Falco's `FalcoAnvilLoader` looks for the region files in
+this order:
+
+1. `worlds//dimensions///region/` — the 26.1 layout,
+ with the dimension of the instance filled in (`minecraft/overworld` for the
+ lobby)
+2. `worlds//region/` — the older layout, used when the directory above
+ does not exist
+
+The lobby worlds are in the older layout, so the fallback is the path that is
+actually taken today. `level.dat` is not read; a directory holding only region
+files is enough. Upgrading a world to a newer Minecraft version is a separate
+job, described in [`world-conversion.md`](world-conversion.md).
+
+## Selecting the active world (US-1.04, US-1.05)
+
+The active world is named by the system property `TITAN_LOBBY_MAP`. It defaults
+to `world`:
+
+```bash
+java -DTITAN_LOBBY_MAP=halloween -jar app-titan.jar
+```
+
+The `:setup` module wires `-DTITAN_LOBBY_MAP=halloween` into its
+`applicationDefaultJvmArgs`, so a setup run edits the Halloween world unless it
+is told otherwise.
+
+The property is evaluated no matter how many worlds are present. That is worth
+stating because it used to not be true: with exactly one world below `worlds/`,
+the old code took that world and never looked at the property, which made a
+machine with one world behave differently from a machine with three.
+
+**A world that is named but not present is not fatal.** The lobby logs a warning
+that carries both halves needed to spot a typo — the name that was searched for
+and the names that were found — and starts with the default world `world`
+instead:
+
+```
+The world 'halloewen' named by the system property TITAN_LOBBY_MAP does not
+exist. Found worlds: world, winter. Falling back to the default world 'world'.
+```
+
+If the default world is missing as well, the first world that was found is used,
+and the same warning says so instead — it names the world that was really taken:
+
+```
+The world 'halloween' named by the system property TITAN_LOBBY_MAP does not
+exist. Found worlds: winter. Falling back to the world 'winter', because the
+default world 'world' is not there either.
+```
+
+Only a `worlds/` directory without any world at all stops the start, because
+there is then nothing left to serve.
+
+Whether the requested world was the one that got selected is readable from
+`MapPool#isRequestedMapSelected()`, so the fallback is observable from code and
+not only from the log.
+
+## Which engine serves the chunks (US-1.01, US-1.02, US-1.03)
+
+`MapProvider` installs `net.onelitefeather.falco.anvil.FalcoAnvilLoader` as the
+chunk loader of the lobby instance, not Minestom's `AnvilLoader`. The reason is
+narrower than "it is ours": Minestom's loader reports a chunk it cannot read as
+absent, the server then generates a fresh chunk in its place, and the next save
+writes that over the built world. Falco's loader reports the failure instead.
+
+The loader keeps region files open for as long as it lives, so it is created
+once per world root and closed on shutdown through `MapProvider#close()` — in
+`:app` and in `:setup`, which is the module that actually changes worlds and
+therefore the one that must not drop a region handle unflushed. Closing also
+takes the closed loader off the instance and removes the listeners of the
+provider, so a player who is still moving during the shutdown gets an empty
+chunk rather than the `IllegalStateException` of a loader that was closed
+underneath them. A closed provider refuses `saveMap`; it does not build a fresh
+loader and reopen what the shutdown has just closed.
+
+Light — block **and** sky — is computed by `falco-light`, driven by a
+`ChunkLightScheduler`: a chunk that arrives is marked together with the eight
+around it, and the pass runs once per instance tick over areas that do not
+overlap. That is what keeps the chunk which loaded first from staying dark along
+the border once its neighbours arrive, and what keeps two parallel loads from
+lighting each other's chunks. `LightingChunk` stays the chunk type of the
+instance for the part Falco does not do: sending the light. See
+[`exploration-lighting.md`](exploration-lighting.md) for the mechanics and for
+what this is the foundation of.
diff --git a/docs/spec-lobby-saison-events.md b/docs/spec-lobby-saison-events.md
index d9cee2a..3a2a516 100644
--- a/docs/spec-lobby-saison-events.md
+++ b/docs/spec-lobby-saison-events.md
@@ -138,12 +138,51 @@ und 3.
| ID | Story | Akzeptanzkriterium (EARS) | Schnittstelle | Priorität | Status |
|---|---|---|---|---|---|
-| US-1.01 | Als Betreiber möchte ich Welten über Falco laden, damit wir unsere eigene Engine nutzen und Ladefehler nicht als „Chunk fehlt" durchgehen. | When eine Instanz erzeugt wird, shall die Lobby einen `FalcoAnvilLoader` als `ChunkLoader` setzen statt Minestoms `AnvilLoader`. | `net.onelitefeather.falco.anvil.FalcoAnvilLoader(Path, Key)` | Must | offen |
-| US-1.02 | Als Betreiber möchte ich, dass ein Lesefehler den Chunk nicht stillschweigend neu generiert, damit gebaute Welten nicht überschrieben werden. | If ein Chunk nicht gelesen werden kann, then shall der Ladevorgang eine Ausnahme werfen und der Chunk shall nicht neu generiert werden. | `AnvilFault`, `ChunkDataException` | Must | offen |
-| US-1.03 | Als Betreiber möchte ich Licht über `falco-light` steuern, damit die Lobby vollständig ausgeleuchtet ist und die Tageszeit später korrekt wirkt. | When ein Chunk geladen wird, shall die Lobby dessen Licht über `ChunkLightService` berechnen. | `ChunkLightService`, `ChunkLightScheduler` | Must | offen |
-| US-1.04 | Als Betreiber möchte ich bei falsch gesetzter Welt-Property eine verständliche Meldung, damit ein Tippfehler kein Rätsel ist. | If die über `TITAN_LOBBY_MAP` benannte Welt nicht existiert, then shall die Lobby den gesuchten Namen und die gefundenen Welten protokollieren und mit der Standardwelt starten. | `MapPool.peekMap()` | Must | offen |
-| US-1.05 | Als Entwickler möchte ich, dass die Welt-Property immer gilt, damit sich lokal und in Produktion nichts unterschiedlich verhält. | The Welt-Auswahl shall die Property unabhängig von der Anzahl vorhandener Welten auswerten. | `MapPool.peekMap()` | Must | offen |
-| US-1.06 | Als Betreiber möchte ich Welten pro Saison als eigenes Verzeichnis ablegen, damit der Wechsel ohne Codeänderung möglich ist. | The Lobby shall die aktive Welt aus einem Verzeichnis unter `worlds/` laden, dessen Name konfigurierbar ist. | `worlds//` | Must | offen |
+| US-1.01 | Als Betreiber möchte ich Welten über Falco laden, damit wir unsere eigene Engine nutzen und Ladefehler nicht als „Chunk fehlt" durchgehen. | When eine Instanz erzeugt wird, shall die Lobby einen `FalcoAnvilLoader` als `ChunkLoader` setzen statt Minestoms `AnvilLoader`. | `net.onelitefeather.falco.anvil.FalcoAnvilLoader(Path, Key)` | Must | umgesetzt |
+| US-1.02 | Als Betreiber möchte ich, dass ein Lesefehler den Chunk nicht stillschweigend neu generiert, damit gebaute Welten nicht überschrieben werden. | If ein Chunk nicht gelesen werden kann, then shall der Ladevorgang eine Ausnahme werfen und der Chunk shall nicht neu generiert werden. | `AnvilFault`, `ChunkDataException` | Must | umgesetzt |
+| US-1.03 | Als Betreiber möchte ich Licht über `falco-light` steuern, damit die Lobby vollständig ausgeleuchtet ist und die Tageszeit später korrekt wirkt. | When ein Chunk geladen wird, shall die Lobby dessen Licht über `ChunkLightService` berechnen. | `ChunkLightService`, `ChunkLightScheduler` | Must | umgesetzt |
+| US-1.04 | Als Betreiber möchte ich bei falsch gesetzter Welt-Property eine verständliche Meldung, damit ein Tippfehler kein Rätsel ist. | If die über `TITAN_LOBBY_MAP` benannte Welt nicht existiert, then shall die Lobby den gesuchten Namen und die gefundenen Welten protokollieren und mit der Standardwelt starten. | `MapPool.peekMap()` | Must | umgesetzt |
+| US-1.05 | Als Entwickler möchte ich, dass die Welt-Property immer gilt, damit sich lokal und in Produktion nichts unterschiedlich verhält. | The Welt-Auswahl shall die Property unabhängig von der Anzahl vorhandener Welten auswerten. | `MapPool.peekMap()` | Must | umgesetzt |
+| US-1.06 | Als Betreiber möchte ich Welten pro Saison als eigenes Verzeichnis ablegen, damit der Wechsel ohne Codeänderung möglich ist. | The Lobby shall die aktive Welt aus einem Verzeichnis unter `worlds/` laden, dessen Name konfigurierbar ist. | `worlds//` | Must | umgesetzt |
+
+**Umsetzungsstand Stufe 1 (28.08.2026).** US-1.01 bis US-1.06 sind umgesetzt;
+Layout und Auswahl der Welten sind in
+[`lobby-world-selection.md`](lobby-world-selection.md) dokumentiert. Vier Punkte,
+die von dieser Spec abweichen und beim Review bekannt sein sollten:
+
+- **Falco-Version.** Die Spec nennt 0.3.0. Aufgelöst wird **2.1.0** — 0.3.0
+ stammt aus der Zeit vor Minestom 26.1. Jedes falco-Artefakt bringt
+ `mycelium-bom` 1.7.2 als Plattform-Abhängigkeit mit, `aonyx-bom` 0.8.0 bringt
+ 1.7.1; ohne Gegenmaßnahme hebt „highest wins" Minestom damit still von
+ `2026.06.05-26.1.2` auf `2026.06.20-26.1.2`. Genau das verbietet NFR-001, und
+ zwar unabhängig davon, ob der konkrete Sprung harmlos ist: die nächste
+ falco-Version würde ihn wiederholen, ohne dass es jemand merkt. `:common`
+ schließt `mycelium-bom` aus allen drei falco-Abhängigkeiten aus. Verifiziert
+ über die aufgelösten Klassenpfade: jedes Modul (`:common`, `:app`, `:setup`,
+ `:bridge`) löst `net.minestom:minestom:2026.06.05-26.1.2` auf — die Version,
+ die `aonyx-bom` vorgibt. Damit ist auch die frühere Abweichung weg, bei der
+ `:bridge` gegen `2026.06.05` kompilierte, während `:app` auf `2026.06.20`
+ lief.
+- **Licht.** Block- **und** Himmelslicht laufen über den
+ `ChunkLightScheduler`; `LightingChunk` bleibt der Chunktyp, weil er das Licht
+ versendet. Ein früherer Stand berechnete das Licht direkt beim Laden über
+ `ChunkLightService.calculateWithNeighbours(...)` — das war in drei Punkten
+ falsch: der zuerst geladene Chunk behielt seinen Rand dauerhaft dunkel (die
+ Schreiboperation löscht das Update-Flag der Section, niemand rechnet erneut),
+ parallel geladene Nachbarchunks belichteten sich gegenseitig (Falcos eigenes
+ Javadoc verbietet überlappende Nachbarschaften), und Himmelslicht wurde
+ überhaupt nicht berechnet, weil eine frische Minestom-`Light` sich selbst als
+ gültig meldet und `LightingChunk` den Himmelspass deshalb überspringt.
+- **`falco-instance` ist Pflicht.** Ein früherer Stand hielt den
+ `ChunkLightScheduler` für unbenutzbar, weil `FalcoLightingChunk` von
+ `FalcoChunk` aus `falco-instance` erbt und falco-light das Modul nicht
+ deklariert. Der Schluss war zu weit: nur `supplier()` nennt diese Klasse. Die
+ Verifikation der Klasse löst den Typ allerdings beim Linken auf, nicht erst
+ beim Ausführen des Lambdas — `new ChunkLightScheduler(...)` scheitert ohne das
+ Modul mit `NoClassDefFoundError`. Titan deklariert `falco-instance` deshalb
+ selbst als Laufzeitabhängigkeit.
+- **US-1.06** war bereits erfüllt und ist nur verifiziert und dokumentiert
+ worden, nicht geändert.
### Stufe 2 — Jahreszeiten und Echtzeit-Tageszeit
@@ -348,9 +387,9 @@ bekommen den Zeitpunkt übergeben, statt selbst auf die Uhr zu sehen. Die
- [x] Die Lizenzfrage Falco ↔ Titan ist entschieden: Titan steht unter AGPL-3.0 (21.08.2026).
- [ ] Die Zustimmung der Mitautoren zum Lizenzwechsel liegt schriftlich vor und ist im Repository abgelegt.
-- [ ] Die Lobby lädt Welten über `FalcoAnvilLoader`; Minestoms `AnvilLoader` wird nicht mehr verwendet.
-- [ ] Ein Lesefehler an einem Chunk führt zu einer Ausnahme, nicht zu einem neu generierten Chunk.
-- [ ] Ein falsch gesetztes `TITAN_LOBBY_MAP` startet die Lobby mit der Standardwelt und protokolliert den gesuchten Namen.
+- [x] Die Lobby lädt Welten über `FalcoAnvilLoader`; Minestoms `AnvilLoader` wird nicht mehr verwendet.
+- [x] Ein Lesefehler an einem Chunk führt zu einer Ausnahme, nicht zu einem neu generierten Chunk.
+- [x] Ein falsch gesetztes `TITAN_LOBBY_MAP` startet die Lobby mit der Standardwelt und protokolliert den gesuchten Namen.
- [ ] Die Tageszeit der Lobby entspricht der Uhrzeit in Berlin, auch über eine Sommerzeitumstellung hinweg.
- [ ] Die Zeitsteuerung ist mit einer festen `Clock` testbar; ein Test prüft Winter im Sommer.
- [ ] Ein Feature lässt sich nacheinander auf intern, lite und ga stellen, ohne dass Code geändert wird.
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 10ce3ec..a33138d 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -11,6 +11,12 @@ dependencyResolutionManagement {
url = uri("https://repo.onelitefeather.dev/onelitefeather-proxy")
}
maven("https://central.sonatype.com/repository/maven-snapshots/")
+ // Falco (chunk loader and light engine) is published to the public
+ // OneLiteFeather release repository.
+ maven {
+ name = "OneLiteFeatherReleases"
+ url = uri("https://repo.onelitefeather.dev/releases")
+ }
maven("https://repository.derklaro.dev/snapshots/")
maven("https://repository.derklaro.dev/releases/")
maven {
@@ -36,6 +42,16 @@ dependencyResolutionManagement {
version("cloudnet", "4.0.0-RC17-SNAPSHOT")
version("butterfly", "1.0.23")
+ // Falco: the OneLiteFeather chunk loader and light engine. 0.3.0 is the
+ // version the lobby spec names, but it predates Minestom 26.1; 2.1.0 is
+ // the current release and the first that builds against Minestom 26.1.
+ // It does not resolve against the Minestom version aonyx-bom pins - it
+ // would override it, because every falco artifact carries mycelium-bom
+ // as a platform dependency and that one is a release ahead. The falco
+ // dependencies of :common exclude that BOM, so the Minestom version
+ // stays the one aonyx-bom prescribes (NFR-001).
+ version("falco", "2.1.0")
+
version("luckperms", "5.6-SNAPSHOT")
version("togglz", "4.6.2")
@@ -56,6 +72,17 @@ dependencyResolutionManagement {
library("adventure.minimessage", "net.kyori", "adventure-text-minimessage").withoutVersion()
library("butterfly-minestom", "net.onelitefeather", "butterfly-minestom").versionRef("butterfly")
+ // Falco
+ library("falco-anvil", "net.onelitefeather", "falco-anvil").versionRef("falco")
+ library("falco-light", "net.onelitefeather", "falco-light").versionRef("falco")
+ // falco-instance is not used directly, but falco-light's ChunkLightScheduler cannot be
+ // loaded without it: the class carries the lambda body of supplier(), which returns a
+ // FalcoLightingChunk, and the verifier resolves that type - and its FalcoChunk
+ // supertype, which lives here - while linking the scheduler, not when the lambda runs.
+ // Without this line the very first `new ChunkLightScheduler(...)` dies with a
+ // NoClassDefFoundError. falco-light declares no dependency on it, so we do.
+ library("falco-instance", "net.onelitefeather", "falco-instance").versionRef("falco")
+
library("togglz", "org.togglz", "togglz-core").versionRef("togglz")
library("caffeine", "com.github.ben-manes.caffeine", "caffeine").versionRef("caffeine")
library("tomcat-annotations-api", "org.apache.tomcat", "annotations-api").versionRef("tomcat-annotations-api")
diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/Titan.java b/setup/src/main/java/net/onelitefeather/titan/setup/Titan.java
index 5a08cb7..1a67ea5 100644
--- a/setup/src/main/java/net/onelitefeather/titan/setup/Titan.java
+++ b/setup/src/main/java/net/onelitefeather/titan/setup/Titan.java
@@ -52,6 +52,17 @@ private Titan() {
initCommands();
initListeners();
+ // The setup server is the one that changes worlds, so it is the one that must not drop its
+ // region handles unflushed. The Falco chunk loader keeps them open for as long as it lives
+ // and MapProvider#close() is the only thing that closes them.
+ MinecraftServer.getSchedulerManager().buildShutdownTask(this::terminate);
+ }
+
+ /**
+ * Closes the map provider and with it every region file the setup server holds open.
+ */
+ public void terminate() {
+ this.mapProvider.close();
}
private void initListeners() {