diff --git a/docs/src/admin/setup.md b/docs/src/admin/setup.md index f6c57628b..46fddcb56 100644 --- a/docs/src/admin/setup.md +++ b/docs/src/admin/setup.md @@ -23,6 +23,57 @@ Check startup: the console should confirm uSkyBlock loaded without dependency errors, and `/island` should work in-game. +## World loading and the void generator + +uSkyBlock creates the skyblock world (`options.general.worldName`, default `skyworld`) +automatically and attaches its void chunk generator every time the server starts. The +generator association is **not** stored in the world data — whenever the world is loaded +without it, new chunks generate regular vanilla terrain instead of void. + +Most setups need no extra configuration. Two setups do: + +### The bukkit.yml generator mapping + +uSkyBlock registers its generator in the server's `bukkit.yml` automatically when it sets +up its worlds: + +```yaml +worlds: + skyworld: + generator: uSkyBlock + skyworld_nether: + generator: uSkyBlock +``` + +This entry is the only mechanism the server consults on *every* load path — including when +the skyblock world is the default world (`level-name` in `server.properties`), which loads +before any plugin can attach a generator. uSkyBlock only adds missing entries; it never +overwrites a generator you configured yourself. If your `bukkit.yml` is not writable +(read-only container images, managed hosting), add the entry above manually — adjust the +world names if you changed `worldName`. + +Note for default-world setups: the entry takes effect at the *next* start, so the very +first start of a brand-new setup with `level-name` pointing at the skyblock world will +still log the generator warning once — restart and the warning goes away; chunks generated +during that first start keep their vanilla terrain (see recovery below). + +### Multiverse + +uSkyBlock imports its worlds into Multiverse-Core automatically, including the correct +generator. If a world is registered in Multiverse without a generator (for example from a +manual `/mv import` without `-g uSkyBlock`), uSkyBlock repairs the registration at startup; +the repair takes effect the next time the world is loaded. + +### Symptoms and recovery + +If the world was ever loaded without the generator, vanilla terrain appears around islands +while the islands themselves look normal, and uSkyBlock logs a severe warning at startup. +Fix the cause as described above, then remove already generated terrain with +`/usb chunk regen ` while standing in the skyblock world — or delete the +affected region files while the server is stopped. Careful: `x`, `z`, and `radius` are +**chunk** coordinates (not block coordinates), and regeneration resets every block in the +affected chunks — including island builds — so keep the radius clear of islands. + ## First setting to check Config lives in `plugins/uSkyBlock/config.yml`. diff --git a/uSkyBlock-Core/build.gradle.kts b/uSkyBlock-Core/build.gradle.kts index 1b18b0234..353e5cf8a 100644 --- a/uSkyBlock-Core/build.gradle.kts +++ b/uSkyBlock-Core/build.gradle.kts @@ -29,6 +29,11 @@ dependencies { compileOnly(libs.net.milkbowl.vault.vaultapi) compileOnly(libs.org.spigotmc.spigot.api) compileOnly(libs.org.mvplugins.multiverse.core.multiverse.core) + testImplementation(libs.org.mvplugins.multiverse.core.multiverse.core) { + // Tests only need Multiverse's own (self-contained) classes; its transitive + // dependencies include JitPack-only artifacts unavailable from our repositories. + isTransitive = false + } compileOnly(libs.org.mvplugins.multiverse.inventories.multiverse.inventories) compileOnly(libs.com.sk89q.worldedit.worldedit.bukkit) testImplementation(libs.com.sk89q.worldedit.worldedit.bukkit) diff --git a/uSkyBlock-Core/src/main/java/us/talabrek/ultimateskyblock/hook/world/MultiverseCoreHook.java b/uSkyBlock-Core/src/main/java/us/talabrek/ultimateskyblock/hook/world/MultiverseCoreHook.java index 04d65cf4f..6c0434a45 100644 --- a/uSkyBlock-Core/src/main/java/us/talabrek/ultimateskyblock/hook/world/MultiverseCoreHook.java +++ b/uSkyBlock-Core/src/main/java/us/talabrek/ultimateskyblock/hook/world/MultiverseCoreHook.java @@ -60,6 +60,7 @@ public void registerOverworld(@NotNull World world) { } MultiverseWorld mvWorld = mvWorldManager.getWorld(world).get(); + ensureGeneratorRegistered(mvWorldManager, mvWorld); mvWorld.setScale(1.0); if (runtimeConfig.general().spawnSize() > 0 && LocationUtil.isEmptyLocation(mvWorld.getSpawnLocation())) { @@ -98,6 +99,7 @@ public void registerNetherworld(@NotNull World world) { } MultiverseWorld mvWorld = mvWorldManager.getWorld(world).get(); + ensureGeneratorRegistered(mvWorldManager, mvWorld); mvWorld.setScale(1.0); if (runtimeConfig.general().spawnSize() > 0 && LocationUtil.isEmptyLocation(mvWorld.getSpawnLocation())) { @@ -112,4 +114,28 @@ public void registerNetherworld(@NotNull World world) { mvWorld.setRespawnWorld(plugin.getWorldManager().getWorld().getName()); } } + + /** + * Ensures the Multiverse registration for the given world has a generator configured. + * Multiverse re-applies the stored generator string every time it loads the world; without + * it, the world loads with the vanilla generator and new chunks get regular terrain. + * The repair takes effect the next time Multiverse loads the world. + * + * @param mvWorldManager Multiverse world manager used to persist the change. + * @param mvWorld Multiverse world registration to check. + */ + void ensureGeneratorRegistered(@NotNull WorldManager mvWorldManager, @NotNull MultiverseWorld mvWorld) { + String generator = mvWorld.getGenerator(); + if (generator == null || generator.isEmpty()) { + logger.warning("Multiverse world '" + mvWorld.getName() + "' has no generator configured. " + + "Setting it to '" + GENERATOR_NAME + "' so it loads as a void world."); + mvWorld.getStringPropertyHandle().setProperty("generator", GENERATOR_NAME) + .flatMap(ignored -> mvWorldManager.saveWorldsConfig()) + .onFailure(failure -> logger.severe("Failed to set Multiverse generator for '" + + mvWorld.getName() + "': " + failure)); + } else if (!generator.equals(GENERATOR_NAME)) { + logger.warning("Multiverse world '" + mvWorld.getName() + "' uses generator '" + generator + + "' instead of '" + GENERATOR_NAME + "'. Leaving it unchanged - make sure it is a void generator."); + } + } } diff --git a/uSkyBlock-Core/src/main/java/us/talabrek/ultimateskyblock/world/BukkitYmlGeneratorMapping.java b/uSkyBlock-Core/src/main/java/us/talabrek/ultimateskyblock/world/BukkitYmlGeneratorMapping.java new file mode 100644 index 000000000..e1450f8ee --- /dev/null +++ b/uSkyBlock-Core/src/main/java/us/talabrek/ultimateskyblock/world/BukkitYmlGeneratorMapping.java @@ -0,0 +1,70 @@ +package us.talabrek.ultimateskyblock.world; + +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; + +/** + * Maintains the {@code worlds..generator} mapping in the server's bukkit.yml. + * The plugin generator association is not stored in the world data, and the bukkit.yml + * mapping is the only mechanism the server consults on every load path - including the + * default (level-name) world, which loads before any plugin can attach a generator. + * Only missing entries are added; existing values are never overwritten. Changes take + * effect on the next server start. + */ +public class BukkitYmlGeneratorMapping { + + private static final String GENERATOR_NAME = "uSkyBlock"; + + private final Path bukkitYml; + private final Logger logger; + + public BukkitYmlGeneratorMapping(@NotNull Path bukkitYml, @NotNull Logger logger) { + this.bukkitYml = bukkitYml; + this.logger = logger; + } + + /** + * Ensures bukkit.yml maps the given world to the uSkyBlock generator. Adds the entry + * when missing, leaves any existing generator untouched, and only logs on failure. + * + * @param worldName Name of the world to register the generator for. + */ + public void ensureMapping(@NotNull String worldName) { + if (!Files.exists(bukkitYml)) { + logger.warning("No bukkit.yml found at " + bukkitYml.toAbsolutePath() + + ", cannot register the world generator for '" + worldName + "'."); + return; + } + + YamlConfiguration config = new YamlConfiguration(); + try { + config.load(bukkitYml.toFile()); + } catch (IOException | InvalidConfigurationException e) { + logger.warning("Could not read bukkit.yml to register the generator for '" + + worldName + "': " + e.getMessage()); + return; + } + String path = "worlds." + worldName + ".generator"; + String existing = config.getString(path); + if (existing == null || existing.isEmpty()) { + config.set(path, GENERATOR_NAME); + try { + config.save(bukkitYml.toFile()); + logger.info("Registered generator '" + GENERATOR_NAME + "' for world '" + worldName + + "' in bukkit.yml, so the world keeps its void generator on every load path."); + } catch (IOException e) { + logger.warning("Could not write bukkit.yml to register the generator for '" + + worldName + "': " + e.getMessage()); + } + } else if (!existing.equals(GENERATOR_NAME)) { + logger.warning("bukkit.yml maps world '" + worldName + "' to generator '" + existing + + "' instead of '" + GENERATOR_NAME + "'. Leaving it unchanged - make sure it is a void generator."); + } + } +} diff --git a/uSkyBlock-Core/src/main/java/us/talabrek/ultimateskyblock/world/WorldManager.java b/uSkyBlock-Core/src/main/java/us/talabrek/ultimateskyblock/world/WorldManager.java index 1795a4b07..bf2cf6e79 100644 --- a/uSkyBlock-Core/src/main/java/us/talabrek/ultimateskyblock/world/WorldManager.java +++ b/uSkyBlock-Core/src/main/java/us/talabrek/ultimateskyblock/world/WorldManager.java @@ -30,6 +30,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; @@ -40,6 +41,7 @@ public class WorldManager { private final RuntimeConfigs runtimeConfigs; private final Scheduler scheduler; private final Logger logger; + private final BukkitYmlGeneratorMapping bukkitYmlGeneratorMapping; public static volatile World skyBlockWorld; public static volatile World skyBlockNetherWorld; @@ -61,6 +63,8 @@ public WorldManager( this.runtimeConfigs = runtimeConfigs; this.logger = logger; this.scheduler = scheduler; + // bukkit.yml resolves against the process working directory, same as the server's own lookup + this.bukkitYmlGeneratorMapping = new BukkitYmlGeneratorMapping(Path.of("bukkit.yml"), logger); } /** @@ -211,6 +215,50 @@ public ChunkGenerator getDefaultWorldGenerator(@NotNull String worldName, @Nulla : getOverworldGenerator(); } + /** + * Checks whether the given {@link World} has the expected {@link ChunkGenerator} attached. + * A world loaded by the server (default world) or another plugin without a generator keeps + * the vanilla generator; Bukkit's createWorld() cannot change it afterwards. + * + * @param world World to check. + * @param expected Generator the world should be using. + * @return True if the attached generator is of the expected class, false otherwise. + */ + static boolean hasExpectedGenerator(@NotNull World world, @NotNull ChunkGenerator expected) { + ChunkGenerator actual = world.getGenerator(); + return actual != null && actual.getClass().getName().equals(expected.getClass().getName()); + } + + /** + * Builds the admin-facing warning logged when a skyblock world runs without the void generator. + * + * @param worldName Name of the affected world. + * @return Warning message, one entry per log line. + */ + static List wrongGeneratorWarning(@NotNull String worldName) { + return List.of( + "============================================================", + "World '" + worldName + "' is loaded WITHOUT the uSkyBlock void", + "generator. New chunks will generate regular vanilla terrain!", + "This happens when the world is loaded before uSkyBlock can", + "attach its generator. uSkyBlock registers the generator in", + "bukkit.yml and Multiverse automatically - usually a RESTART", + "fixes this. If the warning persists after a restart:", + "- Ensure bukkit.yml contains (and is writable):", + " worlds:", + " " + worldName + ":", + " generator: uSkyBlock", + "- Make sure no other plugin loads the world before", + " uSkyBlock created it.", + "Already generated terrain can be removed with", + "'/usb chunk regen '.", + "============================================================"); + } + + private void warnWrongGenerator(@NotNull World world) { + wrongGeneratorWarning(world.getName()).forEach(logger::severe); + } + /** * Gets the skyblock island {@link World}. Creates and/or imports the world if necessary. * @@ -222,11 +270,9 @@ public synchronized World getWorld() { String worldName = runtimeConfigs.current().general().worldName(); skyBlockWorld = Bukkit.getWorld(worldName); ChunkGenerator skyGenerator = getOverworldGenerator(); - ChunkGenerator worldGenerator = skyBlockWorld != null ? skyBlockWorld.getGenerator() : null; if (skyBlockWorld == null || skyBlockWorld.canGenerateStructures() - || worldGenerator == null - || !worldGenerator.getClass().getName().equals(skyGenerator.getClass().getName())) { + || !hasExpectedGenerator(skyBlockWorld, skyGenerator)) { skyBlockWorld = WorldCreator .name(worldName) .type(WorldType.NORMAL) @@ -236,6 +282,11 @@ public synchronized World getWorld() { .createWorld(); skyBlockWorld.save(); } + if (!hasExpectedGenerator(skyBlockWorld, skyGenerator)) { + // createWorld() returns an already-loaded world unchanged, so the generator + // could not be swapped; tell the admin how to fix the setup. + warnWrongGenerator(skyBlockWorld); + } scheduleOverworldSetup(skyBlockWorld); } @@ -260,11 +311,9 @@ public synchronized World getNetherWorld() { String worldName = runtimeConfig.general().worldName(); skyBlockNetherWorld = Bukkit.getWorld(worldName + "_nether"); ChunkGenerator skyGenerator = getNetherGenerator(); - ChunkGenerator worldGenerator = skyBlockNetherWorld != null ? skyBlockNetherWorld.getGenerator() : null; if (skyBlockNetherWorld == null || skyBlockNetherWorld.canGenerateStructures() - || worldGenerator == null - || !worldGenerator.getClass().getName().equals(skyGenerator.getClass().getName())) { + || !hasExpectedGenerator(skyBlockNetherWorld, skyGenerator)) { skyBlockNetherWorld = WorldCreator .name(worldName + "_nether") .type(WorldType.NORMAL) @@ -274,6 +323,11 @@ public synchronized World getNetherWorld() { .createWorld(); skyBlockNetherWorld.save(); } + if (!hasExpectedGenerator(skyBlockNetherWorld, skyGenerator)) { + // createWorld() returns an already-loaded world unchanged, so the generator + // could not be swapped; tell the admin how to fix the setup. + warnWrongGenerator(skyBlockNetherWorld); + } scheduleNetherSetup(skyBlockNetherWorld); } @@ -312,6 +366,7 @@ public boolean isSkyNether(@Nullable World world) { private void scheduleOverworldSetup(@NotNull World world) { scheduler.sync(() -> { + bukkitYmlGeneratorMapping.ensureMapping(world.getName()); hookManager.getWorldHook().ifPresent(hook -> hook.registerOverworld(world)); setupWorld(world, runtimeConfigs.current().island().height()); }); @@ -319,6 +374,7 @@ private void scheduleOverworldSetup(@NotNull World world) { private void scheduleNetherSetup(@NotNull World world) { scheduler.sync(() -> { + bukkitYmlGeneratorMapping.ensureMapping(world.getName()); hookManager.getWorldHook().ifPresent(hook -> hook.registerNetherworld(world)); hookManager.getInventorySyncHook().ifPresent(hook -> hook.linkNetherInventory(getWorld(), world)); setupWorld(world, runtimeConfigs.current().island().height() / 2); diff --git a/uSkyBlock-Core/src/test/java/us/talabrek/ultimateskyblock/hook/world/MultiverseCoreHookTest.java b/uSkyBlock-Core/src/test/java/us/talabrek/ultimateskyblock/hook/world/MultiverseCoreHookTest.java new file mode 100644 index 000000000..aa6baf010 --- /dev/null +++ b/uSkyBlock-Core/src/test/java/us/talabrek/ultimateskyblock/hook/world/MultiverseCoreHookTest.java @@ -0,0 +1,154 @@ +package us.talabrek.ultimateskyblock.hook.world; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mvplugins.multiverse.core.MultiverseCore; +import org.mvplugins.multiverse.core.config.handle.StringPropertyHandle; +import org.mvplugins.multiverse.core.world.MultiverseWorld; +import org.mvplugins.multiverse.core.world.WorldManager; +import org.mvplugins.multiverse.external.vavr.control.Try; +import us.talabrek.ultimateskyblock.config.runtime.RuntimeConfigs; +import us.talabrek.ultimateskyblock.uSkyBlock; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.is; +import static org.mockito.Answers.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class MultiverseCoreHookTest { + + private MultiverseCoreHook hook; + private TestLogHandler logHandler; + private WorldManager mvWorldManager; + + @BeforeEach + public void setUp() { + uSkyBlock plugin = mock(uSkyBlock.class, RETURNS_DEEP_STUBS); + when(plugin.getServer().getPluginManager().getPlugin("Multiverse-Core")) + .thenReturn(mock(MultiverseCore.class)); + + logHandler = new TestLogHandler(); + Logger logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + logger.addHandler(logHandler); + when(plugin.getLogger()).thenReturn(logger); + + hook = new MultiverseCoreHook(plugin, mock(RuntimeConfigs.class)); + mvWorldManager = mock(WorldManager.class); + when(mvWorldManager.saveWorldsConfig()).thenReturn(Try.success(null)); + } + + @Test + public void setsGeneratorWhenMissing() { + MultiverseWorld mvWorld = mock(MultiverseWorld.class); + StringPropertyHandle propertyHandle = mock(StringPropertyHandle.class); + when(mvWorld.getName()).thenReturn("skyworld"); + when(mvWorld.getGenerator()).thenReturn(null); + when(mvWorld.getStringPropertyHandle()).thenReturn(propertyHandle); + when(propertyHandle.setProperty("generator", "uSkyBlock")).thenReturn(Try.success(null)); + + hook.ensureGeneratorRegistered(mvWorldManager, mvWorld); + + verify(propertyHandle).setProperty("generator", "uSkyBlock"); + verify(mvWorldManager).saveWorldsConfig(); + } + + @Test + public void setsGeneratorWhenEmpty() { + MultiverseWorld mvWorld = mock(MultiverseWorld.class); + StringPropertyHandle propertyHandle = mock(StringPropertyHandle.class); + when(mvWorld.getName()).thenReturn("skyworld"); + when(mvWorld.getGenerator()).thenReturn(""); + when(mvWorld.getStringPropertyHandle()).thenReturn(propertyHandle); + when(propertyHandle.setProperty("generator", "uSkyBlock")).thenReturn(Try.success(null)); + + hook.ensureGeneratorRegistered(mvWorldManager, mvWorld); + + verify(propertyHandle).setProperty("generator", "uSkyBlock"); + verify(mvWorldManager).saveWorldsConfig(); + } + + @Test + public void leavesMatchingGeneratorUntouched() { + MultiverseWorld mvWorld = mock(MultiverseWorld.class); + when(mvWorld.getGenerator()).thenReturn("uSkyBlock"); + + hook.ensureGeneratorRegistered(mvWorldManager, mvWorld); + + verify(mvWorld, never()).getStringPropertyHandle(); + verify(mvWorldManager, never()).saveWorldsConfig(); + assertThat(logHandler.warnings(), is(emptyString())); + } + + @Test + public void warnsButKeepsForeignGenerator() { + MultiverseWorld mvWorld = mock(MultiverseWorld.class); + when(mvWorld.getName()).thenReturn("skyworld"); + when(mvWorld.getGenerator()).thenReturn("VoidGen"); + + hook.ensureGeneratorRegistered(mvWorldManager, mvWorld); + + verify(mvWorld, never()).getStringPropertyHandle(); + assertThat(logHandler.warnings(), containsString("VoidGen")); + } + + @Test + public void logsSevereAndSkipsSaveWhenSetPropertyFails() { + MultiverseWorld mvWorld = mock(MultiverseWorld.class); + StringPropertyHandle propertyHandle = mock(StringPropertyHandle.class); + when(mvWorld.getName()).thenReturn("skyworld"); + when(mvWorld.getGenerator()).thenReturn(null); + when(mvWorld.getStringPropertyHandle()).thenReturn(propertyHandle); + when(propertyHandle.setProperty("generator", "uSkyBlock")) + .thenReturn(Try.failure(new RuntimeException("boom"))); + + hook.ensureGeneratorRegistered(mvWorldManager, mvWorld); + + verify(mvWorldManager, never()).saveWorldsConfig(); + assertThat(logHandler.severeMessages(), containsString("skyworld")); + } + + private static final class TestLogHandler extends Handler { + private final List records = new ArrayList<>(); + + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + + String warnings() { + return records.stream() + .filter(record -> record.getLevel() == Level.WARNING) + .map(LogRecord::getMessage) + .collect(Collectors.joining("\n")); + } + + String severeMessages() { + return records.stream() + .filter(record -> record.getLevel() == Level.SEVERE) + .map(LogRecord::getMessage) + .collect(Collectors.joining("\n")); + } + } +} diff --git a/uSkyBlock-Core/src/test/java/us/talabrek/ultimateskyblock/world/BukkitYmlGeneratorMappingTest.java b/uSkyBlock-Core/src/test/java/us/talabrek/ultimateskyblock/world/BukkitYmlGeneratorMappingTest.java new file mode 100644 index 000000000..63aadd6eb --- /dev/null +++ b/uSkyBlock-Core/src/test/java/us/talabrek/ultimateskyblock/world/BukkitYmlGeneratorMappingTest.java @@ -0,0 +1,141 @@ +package us.talabrek.ultimateskyblock.world; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.BeforeEach; +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.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertFalse; + +public class BukkitYmlGeneratorMappingTest { + + @TempDir + Path tempDir; + + private Path bukkitYml; + private BukkitYmlGeneratorMapping mapping; + private TestLogHandler logHandler; + + @BeforeEach + public void setUp() { + bukkitYml = tempDir.resolve("bukkit.yml"); + logHandler = new TestLogHandler(); + Logger logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + logger.addHandler(logHandler); + mapping = new BukkitYmlGeneratorMapping(bukkitYml, logger); + } + + @Test + public void addsMappingWhenMissing() throws IOException { + Files.writeString(bukkitYml, """ + settings: + shutdown-message: Server closed + """); + + mapping.ensureMapping("skyworld"); + + YamlConfiguration config = YamlConfiguration.loadConfiguration(bukkitYml.toFile()); + assertThat(config.getString("worlds.skyworld.generator"), is("uSkyBlock")); + assertThat(config.getString("settings.shutdown-message"), is("Server closed")); + } + + @Test + public void preservesCommentsOnSave() throws IOException { + Files.writeString(bukkitYml, """ + # This is the Bukkit configuration file + settings: + shutdown-message: Server closed + """); + + mapping.ensureMapping("skyworld"); + + assertThat(Files.readString(bukkitYml), containsString("# This is the Bukkit configuration file")); + } + + @Test + public void leavesForeignGeneratorUntouched() throws IOException { + Files.writeString(bukkitYml, """ + worlds: + skyworld: + generator: VoidGen + """); + + mapping.ensureMapping("skyworld"); + + YamlConfiguration config = YamlConfiguration.loadConfiguration(bukkitYml.toFile()); + assertThat(config.getString("worlds.skyworld.generator"), is("VoidGen")); + assertThat(logHandler.warnings(), containsString("VoidGen")); + } + + @Test + public void keepsMatchingMappingWithoutRewrite() throws IOException { + String content = """ + worlds: + skyworld: + generator: uSkyBlock + """; + Files.writeString(bukkitYml, content); + + mapping.ensureMapping("skyworld"); + + assertThat(Files.readString(bukkitYml), is(content)); + } + + @Test + public void unparseableFileIsLeftUntouchedWithWarning() throws IOException { + String content = "worlds:\n\t- this is not valid yaml\n"; + Files.writeString(bukkitYml, content); + + mapping.ensureMapping("skyworld"); + + assertThat(Files.readString(bukkitYml), is(content)); + assertThat(logHandler.warnings(), containsString("bukkit.yml")); + } + + @Test + public void missingFileIsSkippedWithWarning() { + mapping.ensureMapping("skyworld"); + + assertFalse(Files.exists(bukkitYml)); + assertThat(logHandler.warnings(), containsString("bukkit.yml")); + } + + private static final class TestLogHandler extends Handler { + private final List records = new ArrayList<>(); + + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + + String warnings() { + return records.stream() + .filter(record -> record.getLevel() == Level.WARNING) + .map(LogRecord::getMessage) + .collect(Collectors.joining("\n")); + } + } +} diff --git a/uSkyBlock-Core/src/test/java/us/talabrek/ultimateskyblock/world/WorldManagerTest.java b/uSkyBlock-Core/src/test/java/us/talabrek/ultimateskyblock/world/WorldManagerTest.java new file mode 100644 index 000000000..fb46c6750 --- /dev/null +++ b/uSkyBlock-Core/src/test/java/us/talabrek/ultimateskyblock/world/WorldManagerTest.java @@ -0,0 +1,165 @@ +package us.talabrek.ultimateskyblock.world; + +import dk.lockfuglsang.minecraft.util.BukkitServerMock; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.WorldCreator; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import us.talabrek.ultimateskyblock.config.PluginConfigLoader; +import us.talabrek.ultimateskyblock.config.runtime.RuntimeConfig; +import us.talabrek.ultimateskyblock.config.runtime.RuntimeConfigFactory; +import us.talabrek.ultimateskyblock.config.runtime.RuntimeConfigs; +import us.talabrek.ultimateskyblock.gameobject.GameObjectFactory; +import us.talabrek.ultimateskyblock.hook.HookManager; +import us.talabrek.ultimateskyblock.uSkyBlock; +import us.talabrek.ultimateskyblock.util.Scheduler; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class WorldManagerTest { + + private Server server; + private WorldManager worldManager; + private TestLogHandler logHandler; + + @BeforeEach + public void setUp() throws Exception { + server = BukkitServerMock.setupServerMock(); + WorldManager.skyBlockWorld = null; + WorldManager.skyBlockNetherWorld = null; + + logHandler = new TestLogHandler(); + Logger logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + logger.addHandler(logHandler); + + YamlConfiguration config = new YamlConfiguration(); + config.setDefaults(PluginConfigLoader.loadBundledConfig()); + // Separate logger for the factory: config loading may log on its own, which would + // pollute the assertions on logHandler. + RuntimeConfig runtimeConfig = + new RuntimeConfigFactory(new GameObjectFactory(), Logger.getAnonymousLogger()).load(config); + RuntimeConfigs runtimeConfigs = mock(RuntimeConfigs.class); + when(runtimeConfigs.current()).thenReturn(runtimeConfig); + + uSkyBlock plugin = mock(uSkyBlock.class); + when(plugin.getDataFolder()).thenReturn(new File("build/tmp/world-manager-test")); + + worldManager = new WorldManager( + plugin, logger, runtimeConfigs, mock(HookManager.class), mock(Scheduler.class)); + } + + @AfterEach + public void tearDown() { + WorldManager.skyBlockWorld = null; + WorldManager.skyBlockNetherWorld = null; + } + + @Test + public void getWorldWarnsWhenLoadedWorldKeepsWrongGenerator() { + // Simulates the level-name / generator-less Multiverse case: the world is already + // loaded with the vanilla generator, and Bukkit's createWorld() returns it unchanged. + World loaded = mock(World.class); + when(loaded.getName()).thenReturn("skyworld"); + when(loaded.getGenerator()).thenReturn(null); + when(server.getWorld("skyworld")).thenReturn(loaded); + when(server.createWorld(any(WorldCreator.class))).thenReturn(loaded); + + World result = worldManager.getWorld(); + + assertThat(result, is(loaded)); + assertThat(logHandler.severeMessages(), containsString("generator: uSkyBlock")); + assertThat(logHandler.severeMessages(), containsString("skyworld")); + } + + @Test + public void getWorldStaysQuietWhenGeneratorIsAttached() { + World created = mock(World.class); + when(created.getName()).thenReturn("skyworld"); + when(created.getGenerator()).thenReturn(new SkyBlockChunkGenerator()); + when(server.getWorld("skyworld")).thenReturn(null); + when(server.createWorld(any(WorldCreator.class))).thenReturn(created); + + worldManager.getWorld(); + + assertThat(logHandler.severeMessages(), is("")); + } + + @Test + public void hasExpectedGeneratorIsFalseWhenNoGeneratorAttached() { + World world = mock(World.class); + when(world.getGenerator()).thenReturn(null); + + assertFalse(WorldManager.hasExpectedGenerator(world, new SkyBlockChunkGenerator())); + } + + @Test + public void hasExpectedGeneratorIsTrueForSameGeneratorClass() { + World world = mock(World.class); + when(world.getGenerator()).thenReturn(new SkyBlockChunkGenerator()); + + assertTrue(WorldManager.hasExpectedGenerator(world, new SkyBlockChunkGenerator())); + } + + @Test + public void hasExpectedGeneratorIsFalseForDifferentGeneratorClass() { + World world = mock(World.class); + when(world.getGenerator()).thenReturn(new SkyBlockNetherChunkGenerator()); + + assertFalse(WorldManager.hasExpectedGenerator(world, new SkyBlockChunkGenerator())); + } + + @Test + public void wrongGeneratorWarningContainsActionableInstructions() { + List lines = WorldManager.wrongGeneratorWarning("skyworld"); + + String joined = String.join("\n", lines); + assertThat(joined, containsString("skyworld")); + assertThat(joined, containsString("worlds:")); + assertThat(joined, containsString("generator: uSkyBlock")); + assertThat(joined, containsString("/usb chunk regen")); + } + + private static final class TestLogHandler extends Handler { + private final List records = new ArrayList<>(); + + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + + String severeMessages() { + return records.stream() + .filter(record -> record.getLevel() == Level.SEVERE) + .map(LogRecord::getMessage) + .collect(Collectors.joining("\n")); + } + } +}