From 2e93a9e44f6fbb411ee6077aae372a773aeb9687 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 7 Sep 2026 10:49:08 -0700 Subject: [PATCH 1/6] docs: clarify which deaths settings affect Level 2.29.0 island levels Level 2.29.0 tracks deaths per island itself. Update the config comments so admins can see that team-join-reset and reset-on-new only affect BentoBox's own death counter and placeholder, while counted and max still gate what Level records. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SxX9oscerhv1nsozPbT5aX --- src/main/java/world/bentobox/aoneblock/Settings.java | 10 ++++++++-- src/main/resources/config.yml | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) 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/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. From 39ff2e3e96084bea871a75369ff65df8f4472455 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 14 Sep 2026 13:08:38 +0100 Subject: [PATCH 2/6] fix: handle missing phase in BlockListener.processPhase without NPE Sonar (S2259) flagged a guaranteed NullPointerException in processPhase: OneBlocksManager.getPhase() is @Nullable, and the phase returned by handleGoto() was dereferenced without a check. A goto pointing below the first phase, or phase files failing to load, would crash the block-break handler and could leave the island without its magic block. processPhase now resolves the goto first and checks the resulting phase once. When no phase covers the block number it logs an error naming the island and block number, cancels the event so the block stays in place, and returns null; process() returns early on a null result. handlePhaseChange had the same latent requireNonNull and now uses a plain null check that the following line already expected. Adds two BlockListenerTest cases covering no-phase and goto-to-no-phase. Bumps version to 1.27.2. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0161Uw7dZoSjSbSH6T8hMsUQ --- pom.xml | 2 +- .../aoneblock/listeners/BlockListener.java | 21 +++++-- .../listeners/BlockListenerTest.java | 56 +++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/pom.xml b/pom.xml index 630a18b..f276d56 100644 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,7 @@ -LOCAL - 1.27.1 + 1.27.2 BentoBoxWorld_AOneBlock bentobox-world 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/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()}. */ From 33fb8546edcf00b75eb654e43d8151b56cfe2120 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 26 Sep 2026 16:51:27 +0100 Subject: [PATCH 3/6] Update pom.xml - version update --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f276d56..7c16eb2 100644 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,7 @@ -LOCAL - 1.27.2 + 1.28.0 BentoBoxWorld_AOneBlock bentobox-world From 2259bd5f202fb6c2a86584791d55a5de3aaea747 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 26 Sep 2026 16:59:45 +0100 Subject: [PATCH 4/6] feat: add Dappled Forest phase (MC 26.3) and add new shipped phases on upgrade Adds a Dappled Forest phase at the 15500 spot, gated to Minecraft 26.3: poplar logs, yellow/orange/red poplar leaves, leaf litter and red shrub, mobs from the biome's spawn list (passive weights scaled up to balance the hostiles), and chests themed on the new abandoned camp loot. The goto loop moves from 15500 to 16000. Existing phase indexes never picked up phases shipped by later addon versions. The index now records the shipped phases a server has been offered (shippedPhases); on load, a shipped phase that is not in the index and was never offered is added after its shipped predecessor and its files are copied from the jar. Phases an admin removed stay removed. Indexes written before shippedPhases existed treat shipped phases without an addedIn tag as already offered. Co-Authored-By: Claude Opus 5.5 --- .../aoneblock/oneblocks/OneBlocksManager.java | 92 ++++++++- .../resources/phases/15500_dappled_forest.yml | 50 +++++ .../phases/15500_dappled_forest_chests.yml | 110 ++++++++++ .../{15500_goto_0.yml => 16000_goto_0.yml} | 2 +- src/main/resources/phases_index.yml | 14 ++ .../oneblocks/OneBlocksManagerTest3.java | 189 +++++++++++++++++- 6 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/phases/15500_dappled_forest.yml create mode 100644 src/main/resources/phases/15500_dappled_forest_chests.yml rename src/main/resources/phases/{15500_goto_0.yml => 16000_goto_0.yml} (60%) 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/phases/15500_dappled_forest.yml b/src/main/resources/phases/15500_dappled_forest.yml new file mode 100644 index 0000000..833ea40 --- /dev/null +++ b/src/main/resources/phases/15500_dappled_forest.yml @@ -0,0 +1,50 @@ +# 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 + 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..1c8f29b --- /dev/null +++ b/src/main/resources/phases/15500_dappled_forest_chests.yml @@ -0,0 +1,110 @@ +# 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 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/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. From 163a0b305d4bd6162b37b76b70083a2144eb06a0 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 26 Sep 2026 17:03:25 +0100 Subject: [PATCH 5/6] feat: add wool stairs and cushions to the Dappled Forest phase All 16 colours of wool stairs join the block pool at weight 5 each, and three new chests (two common, one uncommon) hold different sets of cushion colours, so which cushions a player finds varies. Both are Minecraft 26.3 items, covered by the phase's existing version gate. Co-Authored-By: Claude Opus 5.5 --- .../resources/phases/15500_dappled_forest.yml | 17 ++++ .../phases/15500_dappled_forest_chests.yml | 99 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/src/main/resources/phases/15500_dappled_forest.yml b/src/main/resources/phases/15500_dappled_forest.yml index 833ea40..e6a71c8 100644 --- a/src/main/resources/phases/15500_dappled_forest.yml +++ b/src/main/resources/phases/15500_dappled_forest.yml @@ -27,6 +27,23 @@ 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 diff --git a/src/main/resources/phases/15500_dappled_forest_chests.yml b/src/main/resources/phases/15500_dappled_forest_chests.yml index 1c8f29b..c4fcce4 100644 --- a/src/main/resources/phases/15500_dappled_forest_chests.yml +++ b/src/main/resources/phases/15500_dappled_forest_chests.yml @@ -108,3 +108,102 @@ 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 From f545f17fac8730ce373edf1184cfef63415c6178 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 26 Sep 2026 17:52:32 +0100 Subject: [PATCH 6/6] build: use ItemsAdder API from Maven Central ItemsAdder moved its API to Maven Central as beer.devs:itemsadder-api, which broke the build against the old maven.devs.beer repository. Switch to the new artifact at 4.0.17 (matching BentoBox) and drop the old repository. Package names are unchanged. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/pom.xml b/pom.xml index 7c16eb2..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 @@ -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