diff --git a/.changeset/warm-badgers-wonder.md b/.changeset/warm-badgers-wonder.md new file mode 100644 index 0000000..9e5f9d0 --- /dev/null +++ b/.changeset/warm-badgers-wonder.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Set Homes now refuses to start when a Set Homes v1 jar is still in your plugins folder, and the console says which file to move and what to run next. Both plugins provide /sethome, /home and /delhome and v1 wins those names, so running the two side by side used to split your players' homes between them with nothing in the log to show for it. diff --git a/README.md b/README.md index a562f56..7d5cb14 100644 --- a/README.md +++ b/README.md @@ -227,9 +227,11 @@ To pick one up, copy the key you want out of [`default-config.yml`](https://gith Your players keep their homes. The old plugin does not even need to be running, because the importer reads its data files directly. +**Coming from Set Homes v1, move the old jar out of `plugins/` first and keep it.** Both plugins provide `/sethome`, `/home` and `/delhome`, and v1 wins those names whatever the load order, so homes created after the upgrade would go into v1's files while the menu read ours. Rather than let that happen quietly, Set Homes refuses to start while a Set Homes v1 jar is installed, and prints what to do in the console. Your server keeps running v1 exactly as before until you move the jar. Leave the `plugins/SetHomes/` folder itself alone; the importer reads it and never writes to it. + 1. Run `/import-homes essentialsx` (or `/import-homes sethomes`). This is a **preview only**. It reports how many homes it would import and warns about any it would skip, and changes nothing. 2. Happy with the numbers? Run it again with `confirm` on the end. -3. Remove the old jar. This plugin provides `/sethome`, `/home` and `/delhome`, and two plugins claiming the same commands will fight over them. +3. Move the old jar out of `plugins/`. Keep it somewhere safe rather than deleting it, so you can go back if you want to. Existing homes are never overwritten, so re-running the import is always safe. Homes in worlds that no longer exist are skipped with a warning naming the world. diff --git a/src/main/java/com/samleighton/sethomestwo/SetHomesTwo.java b/src/main/java/com/samleighton/sethomestwo/SetHomesTwo.java index 9346997..07602f2 100644 --- a/src/main/java/com/samleighton/sethomestwo/SetHomesTwo.java +++ b/src/main/java/com/samleighton/sethomestwo/SetHomesTwo.java @@ -38,8 +38,12 @@ import java.util.Map; import java.util.Objects; import java.util.UUID; +import java.util.logging.Logger; public class SetHomesTwo extends JavaPlugin { + // Set Homes v1's plugin.yml name. Ours differs, so Bukkit loads both happily. + private static final String SET_HOMES_V1 = "SetHomes"; + private final ConnectionManager connectionManager = new ConnectionManager(); private final Map guiSessionMap = new HashMap<>(); private final UsageCounters usageCounters = new UsageCounters(); @@ -56,6 +60,12 @@ public static SetHomesTwo instance() { @Override public void onEnable() { + // Before anything touches disk, so a refused boot leaves the server as it was. + if (Bukkit.getPluginManager().getPlugin(SET_HOMES_V1) != null) { + refuseToRunAlongsideV1(); + return; + } + // Create the directories for the plugin createDirectories(); @@ -109,6 +119,35 @@ public void onEnable() { } } + /** + * Logs why we are not starting and disables us. The text is hardcoded because + * this runs before initConfig, so there is no config to override it from. + */ + private void refuseToRunAlongsideV1() { + Logger log = Bukkit.getLogger(); + log.severe("============================================================"); + log.severe("Set Homes v2 did not start: Set Homes v1 is installed too."); + log.severe(""); + log.severe("Both plugins claim /sethome, /home and /delhome, and v1 takes"); + log.severe("them whatever the load order. Left alone, your players' homes"); + log.severe("would be split between the two plugins with nothing to show"); + log.severe("for it in the logs."); + log.severe(""); + log.severe("To finish the upgrade:"); + log.severe(" 1. Stop the server."); + log.severe(" 2. Move the old SetHomes jar file out of plugins/ and keep it"); + log.severe(" until you have migrated to v2."); + log.severe(" It is how you roll back if you change your mind."); + log.severe(" 3. Leave plugins/SetHomes/ folder where it is. Nothing ever"); + log.severe(" writes to it, but is needed for migrating homes to v2."); + log.severe(" 4. Start the server, then run /import-homes sethomes."); + log.severe(""); + log.severe("Set Homes v1 is still running, exactly as it was."); + log.severe("============================================================"); + + getServer().getPluginManager().disablePlugin(this); + } + @Override public void onDisable() { // Clear teleport attempts for all players diff --git a/src/test/java/com/samleighton/sethomestwo/SetHomesV1ClashTest.java b/src/test/java/com/samleighton/sethomestwo/SetHomesV1ClashTest.java new file mode 100644 index 0000000..93d6d9d --- /dev/null +++ b/src/test/java/com/samleighton/sethomestwo/SetHomesV1ClashTest.java @@ -0,0 +1,150 @@ +package com.samleighton.sethomestwo; + +import com.samleighton.sethomestwo.support.FailOnUnimplemented; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +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.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Boots the plugin by hand rather than through ServerTestBase, because the v1 + * plugin has to be registered before our onEnable runs. + */ +@ExtendWith(FailOnUnimplemented.class) +class SetHomesV1ClashTest { + + private ServerMock server; + + @BeforeEach + void startServer() { + server = MockBukkit.mock(); + } + + @AfterEach + void stopServer() { + MockBukkit.unmock(); + } + + @Test + void refusesToEnableWhenSetHomesV1IsLoaded() { + MockBukkit.createMockPlugin("SetHomes"); + + SetHomesTwo plugin = MockBukkit.load(SetHomesTwo.class); + + assertFalse(plugin.isEnabled(), + "both jars installed splits /sethome and /homes between the two plugins, so we must not enable"); + } + + @Test + void theRefusalSaysWhichFileToMoveAndWhatToRunAfterwards() { + MockBukkit.createMockPlugin("SetHomes"); + + String block = severeText(captureLog(() -> MockBukkit.load(SetHomesTwo.class))); + + assertTrue(block.contains("plugins/"), "should name where the old jar is"); + assertTrue(block.contains("/import-homes sethomes"), "should name the command to run once it is gone"); + assertTrue(block.contains("keep"), "should say to keep the old jar so a rollback stays possible"); + assertFalse(block.contains("delete"), "deleting the old jar throws away the rollback"); + } + + @Test + void aRefusedBootCreatesNothingInOurDataFolder() { + MockBukkit.createMockPlugin("SetHomes"); + + SetHomesTwo plugin = MockBukkit.load(SetHomesTwo.class); + + assertFalse(new File(plugin.getDataFolder(), "config.yml").exists(), + "the guard must run before initConfig"); + assertFalse(new File(plugin.getDataFolder(), "database").exists(), + "the guard must run before createDirectories"); + } + + @Test + void enablesNormallyWhenSetHomesV1IsAbsent() { + assertTrue(loadPlugin().isEnabled()); + } + + @Test + void aPluginWhoseNameOnlyStartsWithSetHomesIsNotV1() { + MockBukkit.createMockPlugin("SetHomesThree"); + + assertTrue(loadPlugin().isEnabled()); + } + + @Test + void theNameMatchIsCaseSensitive() { + MockBukkit.createMockPlugin("sethomes"); + + assertTrue(loadPlugin().isEnabled()); + } + + /** + * Loads for the cases that expect a normal boot, with the same two switches + * ServerTestBase applies so a drained scheduler cannot reach the network. + */ + private SetHomesTwo loadPlugin() { + SetHomesTwo plugin = MockBukkit.load(SetHomesTwo.class); + plugin.getConfig().set("checkForUpdates", false); + + File bStatsDir = new File(plugin.getDataFolder().getParentFile(), "bStats"); + if (!bStatsDir.isDirectory() && !bStatsDir.mkdirs()) + throw new IllegalStateException("could not create " + bStatsDir); + try { + Files.writeString(new File(bStatsDir, "config.yml").toPath(), "enabled: false\n"); + } catch (IOException e) { + throw new IllegalStateException("could not write the bStats opt-out", e); + } + return plugin; + } + + private List captureLog(Runnable action) { + List captured = new ArrayList<>(); + Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + captured.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + + Logger logger = Bukkit.getLogger(); + logger.addHandler(handler); + try { + action.run(); + } finally { + logger.removeHandler(handler); + } + return captured; + } + + private String severeText(List records) { + return records.stream() + .filter(record -> record.getLevel() == Level.SEVERE) + .map(LogRecord::getMessage) + .collect(Collectors.joining("\n")); + } +}