diff --git a/.gitignore b/.gitignore index 3094f55d..06d31c7d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,8 @@ bin/ Thumbs.db # LuckPerms (runtime-generated: config, H2 database, relocated libs) -data/ \ No newline at end of file +# Anchored to the working directories a service runs in: an unanchored "data/" also +# hides the net.onelitefeather.cygnus.setup.data Java package. +/data/ +/setup/data/ +/game/data/ \ No newline at end of file diff --git a/game/build.gradle.kts b/game/build.gradle.kts index 50505410..c84f534c 100644 --- a/game/build.gradle.kts +++ b/game/build.gradle.kts @@ -11,11 +11,13 @@ application { dependencies { implementation(platform(libs.aonyx.bom)) + implementation(platform(libs.falco.bom)) implementation(project(":common")) implementation(libs.slf4j.api) implementation(libs.minestom) implementation(libs.aves) implementation(libs.xerus) + implementation(libs.falco.anvil) // SLF4J needs a binding at runtime; without one it falls back to NOP and the // server logs nothing at all. diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 73a2bbdc..bef7a672 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -100,7 +100,10 @@ public Cygnus() { this.gameConfig = new GameConfigReader(path).getConfig(); MinecraftServer.getConnectionManager().setPlayerProvider(CygnusPlayer::new); this.pageProvider = new PageProvider(); - this.mapProvider = new GameMapProvider(path); + GameMapProvider gameMapProvider = new GameMapProvider(path); + this.mapProvider = gameMapProvider; + // Falco keeps its region files open, so the loaders have to be released on shutdown + MinecraftServer.getSchedulerManager().buildShutdownTask(gameMapProvider::close); this.view = new GameViewImpl(); this.createTeams(this.gameConfig, this.teamService); this.ambientProvider = new AmbientProvider(this.teamService.getTeams().get(TeamHelper.SURVIVOR_TEAM_ID)); diff --git a/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java b/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java index 2d4ad3f0..45f6d90f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java +++ b/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java @@ -3,19 +3,25 @@ import net.minestom.server.MinecraftServer; import net.minestom.server.event.EventDispatcher; import net.minestom.server.instance.InstanceContainer; +import net.minestom.server.world.DimensionType; import net.onelitefeather.cygnus.common.map.GameMap; import net.onelitefeather.cygnus.common.map.filter.MapFilters; import net.onelitefeather.cygnus.common.util.GsonHelper; import net.onelitefeather.cygnus.common.util.Helper; import net.onelitefeather.cygnus.map.event.GameMapLoadedEvent; +import net.onelitefeather.falco.anvil.FalcoAnvilLoader; import net.theevilreaper.aves.map.BaseMap; import net.theevilreaper.aves.map.MapEntry; import net.theevilreaper.aves.map.provider.AbstractMapProvider; +import java.io.IOException; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; public final class GameMapProvider extends AbstractMapProvider { + private final List chunkLoaders = new ArrayList<>(); private InstanceContainer gameInstance; private GameMap gameMap; @@ -41,7 +47,7 @@ public void loadGameMap() { this.gameMap = this.fileHandler.load(gameEntry.getMapFile(), GameMap.class).get(); this.gameInstance = MinecraftServer.getInstanceManager().createInstanceContainer(); this.gameInstance.setTime(Helper.NEW_MOON_TIME); - this.registerInstance(this.gameInstance, gameEntry); + this.registerFalcoInstance(this.gameInstance, gameEntry); EventDispatcher.call(new GameMapLoadedEvent(this.gameMap, this.gameInstance)); } @@ -67,11 +73,55 @@ private BaseMap loadLobbyMap() { this.activeMap = this.fileHandler.load(lobbyEntry.getMapFile(), BaseMap.class).get(); InstanceContainer instanceContainer = MinecraftServer.getInstanceManager().createInstanceContainer(); - this.registerInstance(instanceContainer, lobbyEntry); + this.registerFalcoInstance(instanceContainer, lobbyEntry); this.activeInstance = instanceContainer; return this.activeMap; } + /** + * Registers the given instance with a {@link FalcoAnvilLoader} attached to it. + * + *

This mirrors {@link AbstractMapProvider#registerInstance(InstanceContainer, MapEntry)} but + * swaps the chunk loader: the Aves method hard-wires Minestom's own {@code AnvilLoader}, while + * Falco reads region files in parallel and fails loudly on a broken chunk instead of reporting + * it as absent. The loader is kept so it can be closed in {@link #close()}.

+ * + * @param instance the instance the map is loaded into + * @param mapEntry the map entry whose directory root is the world root of the loader + */ + private void registerFalcoInstance(InstanceContainer instance, MapEntry mapEntry) { + FalcoAnvilLoader chunkLoader = + new FalcoAnvilLoader(mapEntry.getDirectoryRoot(), DimensionType.OVERWORLD.key()); + this.chunkLoaders.add(chunkLoader); + + instance.setChunkLoader(chunkLoader); + instance.enableAutoChunkLoad(true); + + var defaultClock = instance.defaultClock(); + if (defaultClock != null) { + defaultClock.rate(0f); + } + MinecraftServer.getInstanceManager().registerInstance(instance); + } + + /** + * Closes every chunk loader this provider opened. + * + *

Unlike Minestom's {@code AnvilLoader}, a {@link FalcoAnvilLoader} holds its region files + * open, so it has to be closed once the server shuts down. Calling this more than once is + * harmless.

+ */ + public void close() { + for (FalcoAnvilLoader chunkLoader : this.chunkLoaders) { + try { + chunkLoader.close(); + } catch (IOException exception) { + MinecraftServer.getExceptionManager().handleException(exception); + } + } + this.chunkLoaders.clear(); + } + @Override public void saveMap(Path path, BaseMap baseMap) { throw new UnsupportedOperationException(); diff --git a/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java new file mode 100644 index 00000000..bd09c1f6 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java @@ -0,0 +1,111 @@ +package net.onelitefeather.cygnus.map; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.instance.InstanceContainer; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.cygnus.common.map.GameMap; +import net.onelitefeather.cygnus.common.util.GsonHelper; +import net.onelitefeather.falco.anvil.FalcoAnvilLoader; +import net.theevilreaper.aves.map.BaseMap; +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.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +/** + * Verifies that the maps of the game module are read through Falco instead of the chunk loader + * Minestom ships with, for the lobby as well as for the game map. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.6.7 + */ +@ExtendWith(MicrotusExtension.class) +class GameMapProviderIntegrationTest { + + private static final String ARENA_NAME = "arena"; + + @Test + void testLobbyInstanceUsesFalcoChunkLoader(Env env, @TempDir Path root) throws IOException { + GameMapProvider provider = createProvider(root); + + InstanceContainer lobbyInstance = (InstanceContainer) provider.getActiveInstance().get(); + assertNotNull(lobbyInstance); + assertInstanceOf(FalcoAnvilLoader.class, lobbyInstance.getChunkLoader()); + + provider.close(); + env.destroyInstance(lobbyInstance, true); + } + + @Test + void testGameInstanceUsesOwnFalcoChunkLoader(Env env, @TempDir Path root) throws IOException { + GameMapProvider provider = createProvider(root); + + InstanceContainer lobbyInstance = (InstanceContainer) provider.getActiveInstance().get(); + provider.loadGameMap(); + provider.switchToGameMap(); + + InstanceContainer gameInstance = (InstanceContainer) provider.getActiveInstance().get(); + assertNotSame(lobbyInstance, gameInstance); + assertInstanceOf(FalcoAnvilLoader.class, gameInstance.getChunkLoader()); + assertNotSame(lobbyInstance.getChunkLoader(), gameInstance.getChunkLoader()); + assertNotNull(provider.getGameMap()); + + provider.close(); + env.destroyInstance(gameInstance, true); + } + + @Test + void testCloseIsRepeatable(Env env, @TempDir Path root) throws IOException { + GameMapProvider provider = createProvider(root); + InstanceContainer lobbyInstance = (InstanceContainer) provider.getActiveInstance().get(); + + provider.close(); + + assertDoesNotThrow(provider::close); + env.destroyInstance(lobbyInstance, true); + } + + /** + * Creates a map directory layout the provider accepts and returns a provider reading it. + * + * @param root the directory the {@code game/maps} tree is written below + * @return the provider for the written maps + * @throws IOException if the layout cannot be written + */ + private GameMapProvider createProvider(Path root) throws IOException { + Path maps = root.resolve("game").resolve("maps"); + writeMap(maps.resolve("lobby"), new BaseMap("lobby", Pos.ZERO, List.of())); + writeMap( + maps.resolve(ARENA_NAME), + new GameMap(ARENA_NAME, Pos.ZERO, new Pos(1, 1, 1), Set.of(), Set.of(new Pos(2, 2, 2)), List.of()) + ); + return new GameMapProvider(root); + } + + /** + * Writes a map directory the way {@code MapFilters} expects it: a {@code region} directory next + * to a {@code map.json}. The directory stays empty, so the loader simply finds no region file + * and hands out no chunk — which is all this test needs from it. + * + * @param directoryRoot the world root of the map + * @param map the map data written to {@code map.json} + * @throws IOException if the layout cannot be written + */ + private void writeMap(Path directoryRoot, BaseMap map) throws IOException { + Files.createDirectories(directoryRoot.resolve("region")); + Files.writeString(directoryRoot.resolve("map.json"), GsonHelper.GSON.toJson(map), StandardCharsets.UTF_8); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d0387f76..e423b2d4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,6 +32,7 @@ dependencyResolutionManagement { version("luckperms", "5.5") version("luckperms-minestom-loader", "5.6-SNAPSHOT") version("guava", "33.6.0-jre") + version("falco", "1.0.0") library("aonyx.bom", "net.onelitefeather", "aonyx-bom").versionRef("aonyx") library("slf4j.api", "org.slf4j", "slf4j-api").versionRef("slf4j") @@ -51,6 +52,8 @@ dependencyResolutionManagement { library("aves", "net.theevilreaper", "aves").withoutVersion() library("xerus", "net.theevilreaper", "xerus").withoutVersion() library("pica", "net.onelitefeather", "pica").versionRef("pica") + library("falco.bom", "net.onelitefeather", "falco-bom").versionRef("falco") + library("falco.anvil", "net.onelitefeather", "falco-anvil").withoutVersion() library("canis", "com.github.theEvilReaper", "Canis").version("master-SNAPSHOT") library("cloudnet-bom", "eu.cloudnetservice.cloudnet", "bom").versionRef("cloudnet") diff --git a/setup/build.gradle.kts b/setup/build.gradle.kts index 067e57c8..b719871c 100644 --- a/setup/build.gradle.kts +++ b/setup/build.gradle.kts @@ -11,6 +11,7 @@ application { dependencies { implementation(platform(libs.aonyx.bom)) + implementation(platform(libs.falco.bom)) implementation(project(":common")) implementation(libs.slf4j.api) implementation(libs.minestom) @@ -19,6 +20,7 @@ dependencies { implementation(libs.adventure) implementation(libs.pica) implementation(libs.guira) + implementation(libs.falco.anvil) // SLF4J needs a binding at runtime; without one it falls back to NOP and the // server logs nothing at all. diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java index 319609ae..956dac5b 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/GameData.java @@ -7,10 +7,8 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; import net.minestom.server.entity.Player; -import net.minestom.server.instance.anvil.AnvilLoader; -import net.minestom.server.utils.Direction; -import net.minestom.server.world.DimensionType; import net.minestom.server.inventory.InventoryType; +import net.minestom.server.utils.Direction; import net.onelitefeather.cygnus.common.map.GameMap; import net.onelitefeather.cygnus.common.map.GameMapBuilder; import net.onelitefeather.cygnus.common.util.GsonHelper; @@ -327,10 +325,7 @@ public void loadData() { .map(GameMapBuilder::new) .orElseGet(GameMapBuilder::new); - this.instance = MinecraftServer.getInstanceManager().createInstanceContainer(); - - AnvilLoader anvilLoader = new AnvilLoader(this.mapEntry.getDirectoryRoot(), DimensionType.OVERWORLD.key()); - this.instance.setChunkLoader(anvilLoader); + this.createInstance(); this.updateTitle(); MinecraftServer.getInstanceManager().registerInstance(this.instance); diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java index 0f8508ed..f0aed4f6 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/InstanceSetupData.java @@ -8,12 +8,15 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; import net.minestom.server.instance.InstanceContainer; +import net.minestom.server.world.DimensionType; import net.onelitefeather.cygnus.setup.map.MapDataCategory; +import net.onelitefeather.falco.anvil.FalcoAnvilLoader; import net.onelitefeather.guira.data.SetupData; import net.theevilreaper.aves.map.BaseMapBuilder; import net.theevilreaper.aves.map.MapEntry; import org.jetbrains.annotations.Nullable; +import java.io.IOException; import java.util.UUID; /** @@ -34,6 +37,7 @@ public abstract class InstanceSetupData implements SetupData { protected UUID uuid; protected MapEntry mapEntry; protected @Nullable InstanceContainer instance; + protected @Nullable FalcoAnvilLoader chunkLoader; protected BossBar bossBar; protected @Nullable Component title; @@ -119,12 +123,35 @@ public boolean hasMapFile() { } /** - * Resets this setup and unregisters its instance if present. + * Creates the instance this setup works on and attaches a {@link FalcoAnvilLoader} to it. + * + *

The loader reads the world below {@link MapEntry#getDirectoryRoot()} — the world root, not + * its {@code region} directory. It is kept in {@link #chunkLoader} because it holds its region + * files open and has to be closed again in {@link #reset()}.

+ */ + protected void createInstance() { + this.instance = MinecraftServer.getInstanceManager().createInstanceContainer(); + this.chunkLoader = new FalcoAnvilLoader(this.mapEntry.getDirectoryRoot(), DimensionType.OVERWORLD.key()); + this.instance.setChunkLoader(this.chunkLoader); + } + + /** + * Resets this setup, unregisters its instance and closes its chunk loader if present. */ @Override public void reset() { if (instance == null) return; MinecraftServer.getInstanceManager().unregisterInstance(instance); + + if (this.chunkLoader == null) return; + + try { + this.chunkLoader.close(); + } catch (IOException exception) { + MinecraftServer.getExceptionManager().handleException(exception); + } finally { + this.chunkLoader = null; + } } /** diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java index a9ecc6d6..6bb9e6d0 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/data/LobbyData.java @@ -5,8 +5,6 @@ import net.minestom.server.MinecraftServer; import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; -import net.minestom.server.instance.anvil.AnvilLoader; -import net.minestom.server.world.DimensionType; import net.onelitefeather.cygnus.common.util.GsonHelper; import net.onelitefeather.cygnus.setup.inventory.view.InventoryMode; import net.onelitefeather.cygnus.setup.inventory.view.MapDataOverviewInventory; @@ -139,10 +137,7 @@ public void loadData() { .map(BaseMap::builder) .orElseGet(BaseMap::builder); - this.instance = MinecraftServer.getInstanceManager().createInstanceContainer(); - - AnvilLoader anvilLoader = new AnvilLoader(this.mapEntry.getDirectoryRoot(), DimensionType.OVERWORLD.key()); - this.instance.setChunkLoader(anvilLoader); + this.createInstance(); this.updateTitle(); MinecraftServer.getInstanceManager().registerInstance(this.instance); diff --git a/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java new file mode 100644 index 00000000..ff02ff76 --- /dev/null +++ b/setup/src/test/java/net/onelitefeather/cygnus/setup/data/InstanceSetupDataIntegrationTest.java @@ -0,0 +1,115 @@ +package net.onelitefeather.cygnus.setup.data; + +import net.kyori.adventure.bossbar.BossBar; +import net.minestom.server.coordinate.Point; +import net.minestom.server.entity.Player; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.cygnus.setup.map.MapDataCategory; +import net.onelitefeather.falco.anvil.FalcoAnvilLoader; +import net.theevilreaper.aves.map.BaseMap; +import net.theevilreaper.aves.map.BaseMapBuilder; +import net.theevilreaper.aves.map.MapEntry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Verifies that a setup instance reads its world through Falco instead of the chunk loader Minestom + * ships with, and that the loader is released again when the setup is reset. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.6.7 + */ +@ExtendWith(MicrotusExtension.class) +class InstanceSetupDataIntegrationTest { + + @Test + void testInstanceUsesFalcoChunkLoader(Env env, @TempDir Path worldRoot) { + TestSetupData data = new TestSetupData(MapEntry.of(worldRoot)); + data.loadData(); + + assertNotNull(data.instance); + assertNotNull(data.chunkLoader); + assertInstanceOf(FalcoAnvilLoader.class, data.instance.getChunkLoader()); + assertSame(data.chunkLoader, data.instance.getChunkLoader()); + + env.destroyInstance(data.instance, true); + } + + @Test + void testResetClosesChunkLoader(Env env, @TempDir Path worldRoot) { + TestSetupData data = new TestSetupData(MapEntry.of(worldRoot)); + data.loadData(); + + assertNotNull(data.instance); + data.reset(); + + assertNull(data.chunkLoader, "The chunk loader has to be released when the setup is reset"); + } + + @Test + void testResetWithoutInstanceIsHarmless(@TempDir Path worldRoot) { + TestSetupData data = new TestSetupData(MapEntry.of(worldRoot)); + + data.reset(); + + assertNull(data.instance); + assertNull(data.chunkLoader); + } + + /** + * Minimal {@link InstanceSetupData} which only creates the instance, so the test observes the + * chunk loader handling of the base class and nothing else. + */ + private static final class TestSetupData extends InstanceSetupData { + + private TestSetupData(MapEntry mapEntry) { + super(UUID.randomUUID(), mapEntry, BossBar.Color.WHITE); + } + + @Override + public void loadData() { + this.createInstance(); + } + + @Override + public void save() { + // Not part of what this test observes + } + + @Override + public void openInventory(InventoryTarget target) { + // Not part of what this test observes + } + + @Override + public void triggerUpdate(InventoryTarget target) { + // Not part of what this test observes + } + + @Override + public void setPosition(MapDataCategory category, Player player) { + // Not part of what this test observes + } + + @Override + public void handleDataDelete(MapDataCategory category) { + // Not part of what this test observes + } + + @Override + public BaseMapBuilder getMapBuilder() { + return BaseMap.builder(); + } + } +}