diff --git a/pom.xml b/pom.xml index 630a18b..4caae3f 100644 --- a/pom.xml +++ b/pom.xml @@ -57,7 +57,7 @@ 4.110.0 3.22.0 - 4.0.10 + 4.0.17 1.8.0 0.0.67 2.6.2 @@ -67,7 +67,7 @@ -LOCAL - 1.27.1 + 1.28.0 BentoBoxWorld_AOneBlock bentobox-world @@ -147,13 +147,6 @@ true true - - - matteodev - https://maven.devs.beer/ - true - false - nexomc @@ -269,8 +262,9 @@ provided - dev.lone - api-itemsadder + + beer.devs + itemsadder-api ${items-adder.version} provided diff --git a/src/main/java/world/bentobox/aoneblock/Settings.java b/src/main/java/world/bentobox/aoneblock/Settings.java index 0652386..0de952e 100644 --- a/src/main/java/world/bentobox/aoneblock/Settings.java +++ b/src/main/java/world/bentobox/aoneblock/Settings.java @@ -616,18 +616,24 @@ public class Settings implements WorldSettings { // Deaths @ConfigComment("Whether deaths are counted or not.") + @ConfigComment("If false, BentoBox does not count deaths and the Level addon does not record deaths against islands.") @ConfigEntry(path = "island.deaths.counted") private boolean deathsCounted = true; @ConfigComment("Maximum number of deaths to count. The death count can be used by add-ons.") + @ConfigComment("Since Level 2.29.0 this also caps how many deaths each member can contribute to an island's death penalty.") @ConfigEntry(path = "island.deaths.max") private int deathsMax = 10; - @ConfigComment("When a player joins a team, reset their death count") + @ConfigComment("When a player joins a team, reset their death count.") + @ConfigComment("This only affects BentoBox's own per-player death count, used by the %aoneblock_deaths% placeholder.") + @ConfigComment("Since Level 2.29.0, island levels use per-island death tracking and are not affected by this setting.") @ConfigEntry(path = "island.deaths.team-join-reset") private boolean teamJoinDeathReset = true; - @ConfigComment("Reset player death count when they start a new island or reset an island") + @ConfigComment("Reset player death count when they start a new island or reset an island.") + @ConfigComment("This only affects BentoBox's own per-player death count.") + @ConfigComment("Since Level 2.29.0 the Level addon clears an island's own death record automatically when it is reset or deleted.") @ConfigEntry(path = "island.deaths.reset-on-new-island") // , since = "1.6.0") private boolean deathsResetOnNewIsland = true; diff --git a/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java b/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java index 6c006e6..8fc6384 100644 --- a/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java +++ b/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java @@ -423,7 +423,7 @@ private void process(@NonNull Cancellable e, @NonNull Island island, @Nullable P // Process phase changes and requirements ProcessPhaseResult phaseResult = processPhase(e, island, is, player, world, block); - if (e.isCancelled()) { + if (phaseResult == null || e.isCancelled()) { return; } @@ -455,16 +455,29 @@ private record ProcessPhaseResult(OneBlockPhase phase, boolean isCurrPhaseNew, i * @param player - player involved * @param world - world where processing occurs * @param block - block being processed - * @return ProcessPhaseResult containing phase details + * @return ProcessPhaseResult containing phase details, or null if no phase covers the + * island's block number. In that case the event is cancelled so the magic block + * is not lost, and an error is logged for the admin. */ + @Nullable private ProcessPhaseResult processPhase(Cancellable e, Island i, OneBlockIslands is, Player player, World world, Block block) { OneBlockPhase phase = oneBlocksManager.getPhase(is.getBlockNumber()); String prevPhaseName = is.getPhaseName(); - if (Objects.requireNonNull(phase).getGotoBlock() != null) { + if (phase != null && phase.getGotoBlock() != null) { phase = handleGoto(is, phase.getGotoBlock()); } + if (phase == null) { + // No phase covers this block number. This happens when no phase files loaded, + // when the first phase does not start at block 0, or when a goto points below + // the first phase. Keep the block in place rather than breaking the island. + addon.logError("No phase found for block number " + is.getBlockNumber() + " on island " + + i.getUniqueId() + ". Check the phase files in the phases folder."); + e.setCancelled(true); + return null; + } + String currPhaseName = phase.getPhaseName() == null ? "" : phase.getPhaseName(); handlePhaseChange(is, currPhaseName); @@ -516,7 +529,7 @@ private void handleNewPhase(Player player, Island i, OneBlockIslands is, OneBloc */ private void handlePhaseChange(OneBlockIslands is, String currPhaseName) { OneBlockPhase nextPhase = oneBlocksManager.getPhase(is.getBlockNumber() + 1); - if (Objects.requireNonNull(nextPhase).getGotoBlock() != null) { + if (nextPhase != null && nextPhase.getGotoBlock() != null) { nextPhase = oneBlocksManager.getPhase(nextPhase.getGotoBlock()); } String nextPhaseName = nextPhase == null || nextPhase.getPhaseName() == null ? "" : nextPhase.getPhaseName(); diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java index b7c4d18..b0e9f17 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java @@ -12,6 +12,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -95,6 +96,8 @@ public class OneBlocksManager { private static final String INDEX_PHASES = "phases"; private static final String GOTO_AT_END = "gotoAtEnd"; private static final String ADMIN_LENGTHS = "adminLengths"; + private static final String SHIPPED_PHASES = "shippedPhases"; + private static final String ADDED_IN = "addedIn"; private static final String CHESTS_YML_SUFFIX = "_chests.yml"; private static final String WEIGHT = "weight"; /** @@ -115,6 +118,17 @@ public class OneBlocksManager { * then never overwrites lengths from the files' legacy start-block keys. */ private boolean adminLengths; + /** + * Files of the phases shipped in the addon jar that this server has already + * been offered. A shipped phase not in this set is new in this addon version + * and is added to the index; one in it but missing from the index was + * removed by an admin and stays out. + */ + private Set shippedPhases = new LinkedHashSet<>(); + /** + * False when the index predates {@link #shippedPhases} being recorded. + */ + private boolean shippedPhasesRecorded; /** * @param addon - addon @@ -136,6 +150,8 @@ public void loadPhases() throws IOException { phaseIndex = new ArrayList<>(); gotoAtEnd = null; adminLengths = false; + shippedPhases = new LinkedHashSet<>(); + shippedPhasesRecorded = false; // Check for folder File check = new File(addon.getDataFolder(), PHASES); if (check.mkdirs()) { @@ -200,7 +216,8 @@ private boolean loadUsingIndex(File check) { if (entries == null) { return false; } - boolean changed = reconcileIndex(entries, check, freshIndex); + boolean changed = addNewShippedPhases(entries); + changed |= reconcileIndex(entries, check, freshIndex); if (entries.isEmpty()) { return false; } @@ -257,9 +274,79 @@ private List readIndex(File indexFile) { } gotoAtEnd = index.contains(GOTO_AT_END) ? index.getInt(GOTO_AT_END, 0) : null; adminLengths = index.getBoolean(ADMIN_LENGTHS, false); + shippedPhasesRecorded = index.contains(SHIPPED_PHASES); + shippedPhases = new LinkedHashSet<>(index.getStringList(SHIPPED_PHASES)); return entries; } + /** + * Adds phases that are new in this addon version to an existing index. An + * index belongs to the server, so an addon upgrade never replaces it - without + * this, phases shipped by later versions would never appear on servers that + * already have an index. + *

+ * A shipped phase is added when the index has no phase with its file or name + * and it is not in {@link #shippedPhases}, so phases an admin removed are + * never brought back. Indexes written before {@link #shippedPhases} existed + * treat every shipped phase without an {@code addedIn} tag as already offered. + * A new phase goes after the nearest shipped phase above it that the index + * has, and its files are copied from the jar. + * + * @param entries index entries, updated in place + * @return true if the entries or the offered phases changed + */ + boolean addNewShippedPhases(List entries) { + boolean changed = false; + String previous = null; + for (Map map : readShippedIndex()) { + PhaseIndexEntry shipped = PhaseIndexEntry.fromMap(map); + if (shipped == null) { + continue; + } + String file = shipped.getFile(); + boolean offered = shippedPhases.contains(file) || (!shippedPhasesRecorded && !map.containsKey(ADDED_IN)); + boolean inIndex = entries.stream().anyMatch(en -> en.getFile().equals(file) + || (en.getName() != null && en.getName().equalsIgnoreCase(shipped.getName()))); + if (!inIndex && !offered) { + entries.add(positionAfter(entries, previous), shipped); + restorePhaseFileFromJar(file); + addon.log("Phase index: added new phase " + shipped.getName() + + " from this version of the addon. Move or disable it with /oba phases."); + inIndex = true; + } + if (inIndex) { + previous = file; + } + changed |= shippedPhases.add(file); + } + return changed; + } + + /** + * @return the phase entries of the index shipped in the addon jar, or an + * empty list if the jar has none + */ + private List> readShippedIndex() { + try { + return addon.getYamlFromJar(PHASES_INDEX_YML).getMapList(INDEX_PHASES); + } catch (Exception e) { + return List.of(); + } + } + + /** + * @return the position just after the entry for this file, or 0 if there is + * no such entry + */ + private int positionAfter(List entries, @Nullable String file) { + for (int i = 0; i < entries.size(); i++) { + if (entries.get(i).getFile().equals(file)) { + return i + 1; + } + } + return 0; + } + /** * Result of scanning the main phase files in the phases folder: every phase * section with a numeric (legacy start-block) key, phase sections with any @@ -639,6 +726,9 @@ private boolean writeIndex(File indexFile, List entries, @Nulla if (adminLengths) { index.set(ADMIN_LENGTHS, true); } + if (!shippedPhases.isEmpty()) { + index.set(SHIPPED_PHASES, new ArrayList<>(shippedPhases)); + } try { index.save(indexFile); return true; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c366d19..4fffa1d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -560,12 +560,18 @@ island: require-confirmation: true deaths: # Whether deaths are counted or not. + # If false, BentoBox does not count deaths and the Level addon does not record deaths against islands. counted: true # Maximum number of deaths to count. The death count can be used by add-ons. + # Since Level 2.29.0 this also caps how many deaths each member can contribute to an island's death penalty. max: 10 - # When a player joins a team, reset their death count + # When a player joins a team, reset their death count. + # This only affects BentoBox's own per-player death count, used by the %aoneblock_deaths% placeholder. + # Since Level 2.29.0, island levels use per-island death tracking and are not affected by this setting. team-join-reset: true - # Reset player death count when they start a new island or reset an island + # Reset player death count when they start a new island or reset an island. + # This only affects BentoBox's own per-player death count. + # Since Level 2.29.0 the Level addon clears an island's own death record automatically when it is reset or deleted. reset-on-new-island: true protection: # Geo restrict mobs. diff --git a/src/main/resources/phases/15500_dappled_forest.yml b/src/main/resources/phases/15500_dappled_forest.yml new file mode 100644 index 0000000..e6a71c8 --- /dev/null +++ b/src/main/resources/phases/15500_dappled_forest.yml @@ -0,0 +1,67 @@ +# Dappled Forest phase. The dappled_forest biome, poplar wood, poplar leaves +# and red shrub only exist from Minecraft 26.3, so this phase declares a +# required version and is skipped on older servers. +'15500': + name: Dappled Forest + icon: YELLOW_POPLAR_LEAVES + firstBlock: POPLAR_LOG + biome: DAPPLED_FOREST + requiredMinecraftVersion: '26.3' + fixedBlocks: + '0': POPLAR_LOG + '1': YELLOW_POPLAR_LEAVES + '2': ORANGE_POPLAR_LEAVES + '3': RED_POPLAR_LEAVES + '4': POPLAR_SAPLING + '5': RED_SHRUB + blocks: + POPLAR_LOG: 300 + YELLOW_POPLAR_LEAVES: 80 + ORANGE_POPLAR_LEAVES: 80 + RED_POPLAR_LEAVES: 80 + LEAF_LITTER: 80 + RED_SHRUB: 40 + GRASS_BLOCK: 100 + DIRT: 100 + COARSE_DIRT: 30 + STONE: 100 + BROWN_MUSHROOM_BLOCK: 20 + MOSSY_COBBLESTONE: 20 + # Wool stairs in every colour, so each one is a random colour + WHITE_WOOL_STAIRS: 5 + LIGHT_GRAY_WOOL_STAIRS: 5 + GRAY_WOOL_STAIRS: 5 + BLACK_WOOL_STAIRS: 5 + BROWN_WOOL_STAIRS: 5 + RED_WOOL_STAIRS: 5 + ORANGE_WOOL_STAIRS: 5 + YELLOW_WOOL_STAIRS: 5 + LIME_WOOL_STAIRS: 5 + GREEN_WOOL_STAIRS: 5 + CYAN_WOOL_STAIRS: 5 + LIGHT_BLUE_WOOL_STAIRS: 5 + BLUE_WOOL_STAIRS: 5 + PURPLE_WOOL_STAIRS: 5 + MAGENTA_WOOL_STAIRS: 5 + PINK_WOOL_STAIRS: 5 + CHEST: 60 + mobs: + SHEEP: 60 + PIG: 50 + CHICKEN: 50 + COW: 40 + RABBIT: 20 + FOX: 20 + SPIDER: 50 + SKELETON: 50 + CREEPER: 50 + SLIME: 50 + ZOMBIE: 48 + ENDERMAN: 5 + BAT: 5 + WITCH: 3 + ZOMBIE_VILLAGER: 3 + holograms: + '0': '&6Autumn has come to the forest!' + start-commands: [] + end-commands: [] diff --git a/src/main/resources/phases/15500_dappled_forest_chests.yml b/src/main/resources/phases/15500_dappled_forest_chests.yml new file mode 100644 index 0000000..c4fcce4 --- /dev/null +++ b/src/main/resources/phases/15500_dappled_forest_chests.yml @@ -0,0 +1,209 @@ +# Chests for the Dappled Forest phase. Contains 26.3-only items, so this file +# carries the same required version tag as the main phase file. +'15500': + requiredMinecraftVersion: '26.3' + chests: + '1': + contents: + 4: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:poplar_sapling + count: 2 + schema_version: 1 + 12: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:shelf_mushroom + count: 4 + schema_version: 1 + 13: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:bread + count: 4 + schema_version: 1 + 14: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:torch + count: 8 + schema_version: 1 + 22: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:leaf_litter + count: 8 + schema_version: 1 + rarity: COMMON + '2': + contents: + 3: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:poplar_log + count: 16 + schema_version: 1 + 5: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:copper_axe + count: 1 + schema_version: 1 + 11: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:lead + count: 2 + schema_version: 1 + 13: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:straw_bed + count: 1 + schema_version: 1 + 15: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:spyglass + count: 1 + schema_version: 1 + 21: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:rabbit_stew + count: 2 + schema_version: 1 + rarity: UNCOMMON + '3': + contents: + 4: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:iron_spear + count: 1 + schema_version: 1 + 11: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:compass + count: 1 + schema_version: 1 + 13: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:poplar_chest_boat + count: 1 + schema_version: 1 + 15: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:gold_ingot + count: 4 + schema_version: 1 + 22: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:diamond + count: 2 + schema_version: 1 + rarity: RARE + '4': + contents: + 3: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:orange_cushion + count: 1 + schema_version: 1 + 5: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:red_cushion + count: 1 + schema_version: 1 + 13: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:yellow_cushion + count: 1 + schema_version: 1 + 21: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:brown_cushion + count: 1 + schema_version: 1 + 23: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:orange_wool_stairs + count: 4 + schema_version: 1 + rarity: COMMON + '5': + contents: + 3: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:white_cushion + count: 1 + schema_version: 1 + 5: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:light_blue_cushion + count: 1 + schema_version: 1 + 13: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:lime_cushion + count: 1 + schema_version: 1 + 21: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:pink_cushion + count: 1 + schema_version: 1 + 23: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:white_wool_stairs + count: 4 + schema_version: 1 + rarity: COMMON + '6': + contents: + 4: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:purple_cushion + count: 2 + schema_version: 1 + 11: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:cyan_cushion + count: 2 + schema_version: 1 + 13: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:straw_bed + count: 1 + schema_version: 1 + 15: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:magenta_cushion + count: 2 + schema_version: 1 + 22: + ==: org.bukkit.inventory.ItemStack + DataVersion: 5023 + id: minecraft:blue_cushion + count: 2 + schema_version: 1 + rarity: UNCOMMON diff --git a/src/main/resources/phases/15500_goto_0.yml b/src/main/resources/phases/16000_goto_0.yml similarity index 60% rename from src/main/resources/phases/15500_goto_0.yml rename to src/main/resources/phases/16000_goto_0.yml index 0ddc53f..0ad1958 100644 --- a/src/main/resources/phases/15500_goto_0.yml +++ b/src/main/resources/phases/16000_goto_0.yml @@ -1,2 +1,2 @@ -'15500': +'16000': gotoBlock: 0 \ No newline at end of file diff --git a/src/main/resources/phases_index.yml b/src/main/resources/phases_index.yml index 5f1fd7f..a90c818 100644 --- a/src/main/resources/phases_index.yml +++ b/src/main/resources/phases_index.yml @@ -22,8 +22,16 @@ # enabled: optional, defaults to true. Set false to leave a phase out. # requiredMinecraftVersion: optional. The phase is skipped (taking up no # blocks) when the server is older than this version. +# addedIn: optional, in the shipped index only. The addon version that first +# shipped the phase. Used once, when upgrading an index written +# before shippedPhases existed, to tell new phases from ones an +# admin removed. # adminLengths: (top level) set automatically once lengths are edited in the # admin GUI; stops reconciliation from recomputing lengths. +# shippedPhases: (top level) set automatically. Shipped phases this server +# has already been offered. When an addon upgrade ships a new phase, +# it is added to this index once; delete its entry and phase files +# to remove it for good. # # After the last phase, the block count jumps to gotoAtEnd. phases: @@ -108,4 +116,10 @@ phases: name: Sulfur Caves length: 500 requiredMinecraftVersion: '26.2' + - file: 15500_dappled_forest + section: '15500' + name: Dappled Forest + length: 500 + addedIn: '1.28.0' + requiredMinecraftVersion: '26.3' gotoAtEnd: 0 diff --git a/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest.java b/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest.java index fcd087c..786a48d 100644 --- a/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest.java +++ b/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest.java @@ -3,6 +3,8 @@ 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.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -17,7 +19,10 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.EntityType; import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.entity.EntityInteractEvent; import org.eclipse.jdt.annotation.NonNull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -192,6 +197,57 @@ void testOnBlockFromToCenterBlock() { assertTrue(e.isCancelled()); } + /** + * When no phase covers the island's block number (e.g. phase files failed to load or + * a goto points below the first phase), the break must be cancelled and an error logged + * instead of throwing a NullPointerException. + */ + @Test + void testProcessNoPhaseForBlockNumberCancelsAndLogs() { + when(addon.inWorld(any(World.class))).thenReturn(true); + island.setCenter(location); + when(im.getIslandAt(location)).thenReturn(Optional.of(island)); + when(obm.getPhase(anyInt())).thenReturn(null); + + Block block = mock(Block.class); + when(block.getLocation()).thenReturn(location); + when(block.getWorld()).thenReturn(world); + ArmorStand minion = mock(ArmorStand.class); + when(minion.getType()).thenReturn(EntityType.ARMOR_STAND); + + EntityInteractEvent e = new EntityInteractEvent(minion, block); + bl.onBlockBreakByMinion(e); + + assertTrue(e.isCancelled()); + verify(addon).logError(anyString()); + } + + /** + * A phase whose goto target lies below the first phase must also be handled gracefully. + */ + @Test + void testProcessGotoBelowFirstPhaseCancelsAndLogs() { + when(addon.inWorld(any(World.class))).thenReturn(true); + island.setCenter(location); + when(im.getIslandAt(location)).thenReturn(Optional.of(island)); + OneBlockPhase gotoPhase = new OneBlockPhase("0"); + gotoPhase.setGotoBlock(-5); + when(obm.getPhase(0)).thenReturn(gotoPhase); + when(obm.getPhase(-5)).thenReturn(null); + + Block block = mock(Block.class); + when(block.getLocation()).thenReturn(location); + when(block.getWorld()).thenReturn(world); + ArmorStand minion = mock(ArmorStand.class); + when(minion.getType()).thenReturn(EntityType.ARMOR_STAND); + + EntityInteractEvent e = new EntityInteractEvent(minion, block); + bl.onBlockBreakByMinion(e); + + assertTrue(e.isCancelled()); + verify(addon).logError(anyString()); + } + /** * Test method for {@link world.bentobox.aoneblock.listeners.BlockListener#saveCache()}. */ diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java index d82a253..0cc9fb4 100644 --- a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java @@ -66,6 +66,7 @@ public class OneBlocksManagerTest3 extends CommonTestSetup { @Mock private AddonsManager am; private OneBlocksManager obm; + private AOneBlock addon; private OneBlockPhase obPhase; @Mock private @NonNull OneBlockIslands obi; @@ -143,7 +144,7 @@ public void setUp() throws Exception { // MultiLib - prevent BukkitImpl from checking Paper classloader Mockito.mockStatic(MultiLib.class); // Addon - AOneBlock addon = new AOneBlock(); + addon = new AOneBlock(); File dataFolder = new File("addons/AOneBlock"); addon.setDataFolder(dataFolder); addon.setFile(jFile); @@ -168,6 +169,7 @@ public void tearDown() throws Exception { super.tearDown(); deleteAll(new File("database")); cleanPhaseFiles(); + java.nio.file.Files.deleteIfExists(UPGRADE_JAR.toPath()); } @AfterAll @@ -1271,6 +1273,191 @@ void testReconcileKeepsAdminLengths() throws IOException { verify(plugin, never()).logError(anyString()); } + + private static final File UPGRADE_JAR = new File("upgrade-addon.jar"); + + /** + * Points the addon at a jar that ships Plains and Old as baseline phases and + * Fresh as a phase new in this version, together with a shipped index. + */ + private void useJarWithShippedIndex() throws IOException { + Map files = Map.of( + "phases/0_plains.yml", """ + '0': + name: Plains + biome: PLAINS + blocks: + GRASS_BLOCK: 100 + """, + "phases/100_old.yml", """ + '100': + name: Old + biome: PLAINS + blocks: + STONE: 100 + """, + "phases/200_fresh.yml", """ + '200': + name: Fresh + biome: PLAINS + blocks: + DIRT: 100 + """, + "phases_index.yml", """ + phases: + - file: 0_plains + section: '0' + name: Plains + length: 100 + - file: 100_old + section: '100' + name: Old + length: 100 + - file: 200_fresh + section: '200' + name: Fresh + length: 300 + addedIn: '9.9.9' + gotoAtEnd: 0 + """); + try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(UPGRADE_JAR))) { + for (Map.Entry en : files.entrySet()) { + jar.putNextEntry(new JarEntry(en.getKey())); + jar.write(en.getValue().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + jar.closeEntry(); + } + } + addon.setFile(UPGRADE_JAR); + } + + private void writePlainsPhaseFile() throws IOException { + PHASES_DIR.mkdirs(); + java.nio.file.Files.writeString(new File(PHASES_DIR, "0_plains.yml").toPath(), """ + '0': + name: Plains + biome: PLAINS + blocks: + GRASS_BLOCK: 100 + """); + } + + /** + * Upgrading an index written before shippedPhases existed adds the phase the + * new version ships, copies its file from the jar, and records every shipped + * phase as offered. The baseline phase an admin had removed stays out. + */ + @Test + void testUpgradeAddsNewShippedPhase() throws IOException, InvalidConfigurationException { + useJarWithShippedIndex(); + writePlainsPhaseFile(); + java.nio.file.Files.writeString(INDEX_FILE.toPath(), """ + phases: + - file: 0_plains + section: '0' + name: Plains + length: 100 + gotoAtEnd: 0 + """); + obm.loadPhases(); + List index = obm.getPhaseIndex(); + assertEquals(List.of("Plains", "Fresh"), index.stream().map(PhaseIndexEntry::getName).toList()); + assertEquals(300, index.get(1).getLength()); + assertTrue(new File(PHASES_DIR, "200_fresh.yml").exists(), "New phase file should be copied from the jar"); + assertFalse(new File(PHASES_DIR, "100_old.yml").exists(), "Removed baseline phase should stay removed"); + assertEquals("Fresh", obm.getPhase(100).getPhaseName()); + verify(plugin).log(org.mockito.ArgumentMatchers.contains("added new phase Fresh")); + YamlConfiguration saved = new YamlConfiguration(); + saved.load(INDEX_FILE); + assertEquals(List.of("0_plains", "100_old", "200_fresh"), saved.getStringList("shippedPhases")); + verify(plugin, never()).logError(anyString()); + } + + /** + * A new shipped phase goes after the nearest shipped phase above it in the + * shipped order, wherever the admin has moved that phase - here Old, which + * the admin moved to the top. + */ + @Test + void testUpgradeInsertsAfterShippedPredecessor() throws IOException { + useJarWithShippedIndex(); + writePlainsPhaseFile(); + java.nio.file.Files.writeString(new File(PHASES_DIR, "100_old.yml").toPath(), """ + '100': + name: Old + biome: PLAINS + blocks: + STONE: 100 + """); + java.nio.file.Files.writeString(new File(PHASES_DIR, "custom.yml").toPath(), """ + custom: + name: Custom + biome: PLAINS + blocks: + SAND: 100 + """); + java.nio.file.Files.writeString(INDEX_FILE.toPath(), """ + phases: + - file: 100_old + section: '100' + name: Old + length: 100 + - file: 0_plains + section: '0' + name: Plains + length: 100 + - file: custom + section: custom + name: Custom + length: 100 + gotoAtEnd: 0 + """); + obm.loadPhases(); + assertEquals(List.of("Old", "Fresh", "Plains", "Custom"), + obm.getPhaseIndex().stream().map(PhaseIndexEntry::getName).toList()); + } + + /** + * A shipped phase already offered to this server and then removed by an admin + * is not brought back, and a load with nothing new leaves the index alone. + */ + @Test + void testRemovedShippedPhaseStaysRemoved() throws IOException { + useJarWithShippedIndex(); + writePlainsPhaseFile(); + java.nio.file.Files.writeString(INDEX_FILE.toPath(), """ + phases: + - file: 0_plains + section: '0' + name: Plains + length: 100 + gotoAtEnd: 0 + shippedPhases: + - 0_plains + - 100_old + - 200_fresh + """); + obm.loadPhases(); + assertEquals(1, obm.getPhaseIndex().size()); + assertFalse(new File(PHASES_DIR, "200_fresh.yml").exists()); + verify(plugin, never()).log(org.mockito.ArgumentMatchers.contains("added new phase")); + verify(plugin, never()).log(org.mockito.ArgumentMatchers.contains("Updated")); + } + + /** + * A fresh install copies the shipped index, which already has every phase, so + * nothing is added - the shipped phases are just recorded as offered. + */ + @Test + void testFreshInstallRecordsShippedPhases() throws IOException, InvalidConfigurationException { + useJarWithShippedIndex(); + obm.loadPhases(); + assertEquals(3, obm.getPhaseIndex().size()); + verify(plugin, never()).log(org.mockito.ArgumentMatchers.contains("added new phase")); + YamlConfiguration saved = new YamlConfiguration(); + saved.load(INDEX_FILE); + assertEquals(List.of("0_plains", "100_old", "200_fresh"), saved.getStringList("shippedPhases")); + } + /** * When index and folder already agree, reconciliation changes nothing and the * index is not rewritten - admin-set order, lengths, and enabled flags stick.