From 2649c70fa7aa4b63249fa0fdc4c6e0a0fdaa9379 Mon Sep 17 00:00:00 2001 From: BONNe Date: Fri, 10 Jul 2020 01:32:57 +0300 Subject: [PATCH 01/19] Update pom.xml --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f896d03..3996b41 100644 --- a/pom.xml +++ b/pom.xml @@ -45,7 +45,7 @@ X.Y.Z -> BentoBox core version .M -> Addon development iteration. --> - 1.11.0.3 + 1.11.0.4 -LOCAL From e02f38aef18fac02f4200f05f5359df957fb54f0 Mon Sep 17 00:00:00 2001 From: BONNe Date: Fri, 17 Dec 2021 08:00:11 +0200 Subject: [PATCH 02/19] Update to Minecraft 1.18 biome changes --- pom.xml | 4 ++-- .../world/bentobox/extramobs/listeners/MobsSpawnListener.java | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 3996b41..0b65d51 100644 --- a/pom.xml +++ b/pom.xml @@ -35,7 +35,7 @@ 1.8 1.7.4 - 1.16.1-R0.1-SNAPSHOT + 1.18.1-R0.1-SNAPSHOT 1.11.0 ${build.version}-SNAPSHOT @@ -45,7 +45,7 @@ X.Y.Z -> BentoBox core version .M -> Addon development iteration. --> - 1.11.0.4 + 1.12 -LOCAL diff --git a/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java b/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java index b4e3f20..9e0deca 100644 --- a/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java +++ b/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java @@ -118,8 +118,7 @@ else if (world.getEnvironment() == World.Environment.NORMAL && if (biome == Biome.DEEP_OCEAN || biome == Biome.DEEP_COLD_OCEAN || biome == Biome.DEEP_FROZEN_OCEAN || - biome == Biome.DEEP_LUKEWARM_OCEAN || - biome == Biome.DEEP_WARM_OCEAN) + biome == Biome.DEEP_LUKEWARM_OCEAN) { // Monuments are located only in Deep Ocean. So guardians will spawn there. From 012b07d9c36f3bedc17367c027ebf33ca8d222c1 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 13 Jan 2024 07:24:18 -0800 Subject: [PATCH 03/19] Update pom.xml 1.13.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 0b65d51..e43a754 100644 --- a/pom.xml +++ b/pom.xml @@ -45,7 +45,7 @@ X.Y.Z -> BentoBox core version .M -> Addon development iteration. --> - 1.12 + 1.13 -LOCAL From cc6db10e52444704252d5f90e7063ec099186604 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 13 Jan 2024 09:27:23 -0800 Subject: [PATCH 04/19] Update to latest Minecraft API --- pom.xml | 147 ++++++++++++------ .../listeners/MobsSpawnListener.java | 17 +- 2 files changed, 111 insertions(+), 53 deletions(-) diff --git a/pom.xml b/pom.xml index e43a754..254318a 100644 --- a/pom.xml +++ b/pom.xml @@ -32,11 +32,11 @@ UTF-8 UTF-8 - 1.8 + 17 1.7.4 - 1.18.1-R0.1-SNAPSHOT - 1.11.0 + 1.20.4-R0.1-SNAPSHOT + 2.0.0-SNAPSHOT ${build.version}-SNAPSHOT @@ -49,8 +49,12 @@ -LOCAL + + + ci @@ -60,10 +64,17 @@ -b${env.BUILD_NUMBER} - + + + + master @@ -74,28 +85,21 @@ ${build.version} - + - - - codemc-snapshots - https://repo.codemc.org/repository/maven-snapshots - - - codemc-releases - https://repo.codemc.org/repository/maven-releases - - - spigot-repo https://hub.spigotmc.org/nexus/content/repositories/snapshots + + codemc + https://repo.codemc.org/repository/maven-snapshots/ + codemc-repo https://repo.codemc.org/repository/maven-public/ @@ -103,16 +107,18 @@ + org.spigotmc spigot-api ${spigot.version} provided + org.mockito - mockito-all - 1.10.19 + mockito-core + 3.11.1 test @@ -123,7 +129,7 @@ org.powermock - powermock-api-mockito + powermock-api-mockito2 ${powermock.version} test @@ -136,9 +142,18 @@ + + + + + + ${project.name}-${revision}${build.number} clean package + src/main/resources @@ -152,6 +167,15 @@ *.yml + + src/main/resources/blueprints + ./blueprints + false + + *.blu + *.json + + @@ -163,20 +187,57 @@ org.apache.maven.plugins maven-resources-plugin 3.1.0 + + + blu + + org.apache.maven.plugins maven-compiler-plugin - 3.7.0 + 3.8.1 - ${java.version} - ${java.version} + ${java.version} org.apache.maven.plugins maven-surefire-plugin - 2.22.0 + 3.1.2 + + + + ${argLine} + --add-opens java.base/java.lang=ALL-UNNAMED + --add-opens java.base/java.math=ALL-UNNAMED + --add-opens java.base/java.io=ALL-UNNAMED + --add-opens java.base/java.util=ALL-UNNAMED + --add-opens + java.base/java.util.stream=ALL-UNNAMED + --add-opens java.base/java.text=ALL-UNNAMED + --add-opens + java.base/java.util.regex=ALL-UNNAMED + --add-opens + java.base/java.nio.channels.spi=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED + --add-opens java.base/java.net=ALL-UNNAMED + --add-opens + java.base/java.util.concurrent=ALL-UNNAMED + --add-opens java.base/sun.nio.fs=ALL-UNNAMED + --add-opens java.base/sun.nio.cs=ALL-UNNAMED + --add-opens java.base/java.nio.file=ALL-UNNAMED + --add-opens + java.base/java.nio.charset=ALL-UNNAMED + --add-opens + java.base/java.lang.reflect=ALL-UNNAMED + --add-opens + java.logging/java.util.logging=ALL-UNNAMED + --add-opens java.base/java.lang.ref=ALL-UNNAMED + --add-opens java.base/java.util.jar=ALL-UNNAMED + --add-opens java.base/java.util.zip=ALL-UNNAMED + + org.apache.maven.plugins @@ -186,15 +247,17 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.0.1 + 3.3.0 - public false -Xdoclint:none + + ${java.home}/bin/javadoc attach-javadocs + install jar @@ -214,22 +277,6 @@ - - org.apache.maven.plugins - maven-shade-plugin - 3.1.1 - - true - - - - package - - shade - - - - org.apache.maven.plugins maven-install-plugin @@ -243,22 +290,34 @@ org.jacoco jacoco-maven-plugin - 0.8.1 + 0.8.10 true + + + **/*Names* + + org/bukkit/Material* + - pre-unit-test + prepare-agent prepare-agent - post-unit-test + report report + + + XML + + diff --git a/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java b/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java index 9e0deca..6b4d004 100644 --- a/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java +++ b/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java @@ -1,6 +1,9 @@ package world.bentobox.extramobs.listeners; +import java.util.Optional; +import java.util.Random; + import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; @@ -8,15 +11,13 @@ import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.entity.EntityType; +import org.bukkit.entity.Fish; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.entity.CreatureSpawnEvent; import org.eclipse.jdt.annotation.NonNull; -import java.util.Optional; -import java.util.Random; - import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.extramobs.ExtraMobsAddon; @@ -68,8 +69,9 @@ public void onEntitySpawn(CreatureSpawnEvent event) } - if ((event.getEntityType().name().equals("PIG_ZOMBIE") || - event.getEntityType().name().equals("ZOMBIFIED_PIGLIN")) && + if ((event.getEntityType().equals(EntityType.ZOMBIFIED_PIGLIN) + || event.getEntityType().equals(EntityType.PIGLIN)) + && this.addon.getPlugin().getIWM().isIslandNether(world)) { // replace pigmen with blaze or wither @@ -104,10 +106,7 @@ else if (event.getEntityType() == EntityType.ENDERMAN && } } } - else if (world.getEnvironment() == World.Environment.NORMAL && - (event.getEntityType() == EntityType.COD || - event.getEntityType() == EntityType.SALMON || - event.getEntityType() == EntityType.TROPICAL_FISH)) + else if (world.getEnvironment() == World.Environment.NORMAL && event.getEntity() instanceof Fish) { // Check biome Biome biome = world.getBiome( From c69ecb29b21fe16d9b7eaf894c6e0f9c58152e03 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 13 Jan 2024 11:07:40 -0800 Subject: [PATCH 05/19] Added test class --- pom.xml | 2 +- .../listeners/MobsSpawnListener.java | 12 +- .../listeners/MobsSpawnListenerTest.java | 159 ++++++++++++++++++ 3 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java diff --git a/pom.xml b/pom.xml index 254318a..246a644 100644 --- a/pom.xml +++ b/pom.xml @@ -33,7 +33,7 @@ UTF-8 UTF-8 17 - 1.7.4 + 2.0.9 1.20.4-R0.1-SNAPSHOT 2.0.0-SNAPSHOT diff --git a/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java b/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java index 6b4d004..04e22d5 100644 --- a/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java +++ b/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java @@ -60,24 +60,25 @@ public void onEntitySpawn(CreatureSpawnEvent event) this.addon.getPlugin().getIWM().getAddon(world); if (!optionalAddon.isPresent() || - !this.addon.getSettings().getDisabledGameModes().isEmpty() && + (!this.addon.getSettings().getDisabledGameModes().isEmpty() + && this.addon.getSettings().getDisabledGameModes().contains( - optionalAddon.get().getDescription().getName())) + optionalAddon.get().getDescription().getName()))) { // GameMode addon is not in enable list. return; } - if ((event.getEntityType().equals(EntityType.ZOMBIFIED_PIGLIN) || event.getEntityType().equals(EntityType.PIGLIN)) - && - this.addon.getPlugin().getIWM().isIslandNether(world)) + && this.addon.getPlugin().getIWM().isIslandNether(world)) { + // replace pigmen with blaze or wither if (this.isSuitableNetherLocation(event.getLocation())) { + if (this.spawningRandom.nextDouble() < this.addon.getSettings().getWitherSkeletonChance()) { // oOo wither skeleton got lucky. @@ -108,6 +109,7 @@ else if (event.getEntityType() == EntityType.ENDERMAN && } else if (world.getEnvironment() == World.Environment.NORMAL && event.getEntity() instanceof Fish) { + // Check biome Biome biome = world.getBiome( event.getLocation().getBlockX(), diff --git a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java new file mode 100644 index 0000000..d5de2d3 --- /dev/null +++ b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java @@ -0,0 +1,159 @@ +package world.bentobox.extramobs.listeners; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Biome; +import org.bukkit.block.Block; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Fish; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.eclipse.jdt.annotation.NonNull; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.powermock.modules.junit4.PowerMockRunner; + +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.addons.AddonDescription; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.managers.IslandWorldManager; +import world.bentobox.extramobs.ExtraMobsAddon; +import world.bentobox.extramobs.config.Settings; + +@RunWith(PowerMockRunner.class) +public class MobsSpawnListenerTest { + + @Mock + private ExtraMobsAddon addon; + + @Mock + private CreatureSpawnEvent event; + + @Mock + private World world; + + private MobsSpawnListener listener; + + private Settings settings; + + @Mock + private BentoBox plugin; + + @Mock + private IslandWorldManager iwm; + + @Mock + private GameModeAddon gma; + + @Mock + private Location location; + + @Mock + private Block block; + + @Before + public void setUp() { + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + + when(addon.getPlugin()).thenReturn(plugin); + + when(plugin.getIWM()).thenReturn(iwm); + + when(iwm.getAddon(world)).thenReturn(Optional.of(gma)); + + when(iwm.isIslandEnd(world)).thenReturn(true); + when(iwm.isIslandNether(world)).thenReturn(true); + + @NonNull + AddonDescription desc = new AddonDescription.Builder("main", "bskyblock", "1.0.0").build(); + + when(gma.getDescription()).thenReturn(desc); + + // Location + when(location.getBlock()).thenReturn(block); + when(location.getWorld()).thenReturn(world); + + when(block.getRelative(any())).thenReturn(block); + + when(block.getType()).thenReturn(Material.STONE); + + // Initialize mocks and the class to test + listener = new MobsSpawnListener(addon); + } + + // Test case for natural spawning of Zombified Piglin in the Nether + @Test + public void testNaturalSpawnZombifiedPiglinNether() { + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); + when(event.getSpawnReason()).thenReturn(CreatureSpawnEvent.SpawnReason.NATURAL); + when(event.getLocation()).thenReturn(location); + when(world.getEnvironment()).thenReturn(World.Environment.NETHER); + when(addon.getPlugin().getIWM().isIslandNether(world)).thenReturn(true); + settings.setWitherSkeletonChance(1.1); // Set so that it will always spawn + when(block.getType()).thenReturn(Material.NETHER_BRICKS); + + listener.onEntitySpawn(event); + + verify(event).setCancelled(true); + // Additional verifications can be added to check if the correct entity was spawned + } + + // Test case for natural spawning of Enderman in the End + @Test + public void testNaturalSpawnEndermanEnd() { + when(event.getEntityType()).thenReturn(EntityType.ENDERMAN); + when(event.getSpawnReason()).thenReturn(CreatureSpawnEvent.SpawnReason.NATURAL); + when(event.getLocation()).thenReturn(location); + when(world.getEnvironment()).thenReturn(World.Environment.THE_END); + when(addon.getPlugin().getIWM().isIslandEnd(world)).thenReturn(true); + settings.setShulkerChance(1.1); // Set so that it will always spawn + when(block.getType()).thenReturn(Material.PURPUR_BLOCK); + + listener.onEntitySpawn(event); + + verify(event).setCancelled(true); + // Additional verifications can be added to check if the correct entity was spawned + } + + // Test case for spawning of Fish in Deep Ocean biome + @Test + public void testFishSpawnDeepOcean() { + Fish fish = mock(Fish.class); + when(event.getEntity()).thenReturn(fish); + when(event.getEntityType()).thenReturn(EntityType.TROPICAL_FISH); + when(event.getSpawnReason()).thenReturn(CreatureSpawnEvent.SpawnReason.NATURAL); + when(event.getLocation()).thenReturn(location); + when(world.getEnvironment()).thenReturn(World.Environment.NORMAL); + when(world.getBiome(anyInt(), anyInt(), anyInt())).thenReturn(Biome.DEEP_OCEAN); + settings.setGuardianChance(1.1); // Set so that it will always spawn + when(block.getType()).thenReturn(Material.WATER, Material.WATER, Material.WATER, Material.PRISMARINE); + + listener.onEntitySpawn(event); + + verify(event).setCancelled(true); + // Additional verifications for guardian spawning + } + + // Test case for non-natural spawning + @Test + public void testNonNaturalSpawn() { + when(event.getSpawnReason()).thenReturn(CreatureSpawnEvent.SpawnReason.SPAWNER); + + listener.onEntitySpawn(event); + + verify(event, never()).isCancelled(); + } + +} From f79346eddde729bbcceb0e8dc99f12df17fa205e Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 14 Jan 2024 10:05:58 -0800 Subject: [PATCH 06/19] Add distribution management section to POM --- pom.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pom.xml b/pom.xml index 246a644..9d261cf 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,17 @@ GitHub https://github.com/BentoBoxWorld/ExtraMobs/issues + + + + codemc-snapshots + https://repo.codemc.org/repository/maven-snapshots + + + codemc-releases + https://repo.codemc.org/repository/maven-releases + + UTF-8 From 6bbccd997c0a1c5dbf3cafcedac669dbe69286ee Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 9 Nov 2024 15:39:43 -0800 Subject: [PATCH 07/19] Update to MC 1.21.4 and CodeMC changes (#16) --- pom.xml | 18 +-- .../listeners/MobsSpawnListenerTest.java | 15 ++- .../listeners/mocks/ServerMocks.java | 118 ++++++++++++++++++ 3 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java diff --git a/pom.xml b/pom.xml index 9d261cf..5331fcd 100644 --- a/pom.xml +++ b/pom.xml @@ -30,13 +30,9 @@ - - codemc-snapshots - https://repo.codemc.org/repository/maven-snapshots - - codemc-releases - https://repo.codemc.org/repository/maven-releases + bentoboxworld + https://repo.codemc.org/repository/bentoboxworld @@ -46,8 +42,8 @@ 17 2.0.9 - 1.20.4-R0.1-SNAPSHOT - 2.0.0-SNAPSHOT + 1.21.3-R0.1-SNAPSHOT + 2.7.1-SNAPSHOT ${build.version}-SNAPSHOT @@ -56,7 +52,7 @@ X.Y.Z -> BentoBox core version .M -> Addon development iteration. --> - 1.13 + 1.14.0 -LOCAL @@ -107,6 +103,10 @@ spigot-repo https://hub.spigotmc.org/nexus/content/repositories/snapshots + + bentoboxworld + https://repo.codemc.org/repository/bentoboxworld/ + codemc https://repo.codemc.org/repository/maven-snapshots/ diff --git a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java index d5de2d3..cf2431e 100644 --- a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java +++ b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java @@ -1,7 +1,6 @@ package world.bentobox.extramobs.listeners; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -12,24 +11,27 @@ import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; -import org.bukkit.block.Biome; import org.bukkit.block.Block; import org.bukkit.entity.EntityType; import org.bukkit.entity.Fish; import org.bukkit.event.entity.CreatureSpawnEvent; import org.eclipse.jdt.annotation.NonNull; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.powermock.modules.junit4.PowerMockRunner; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.addons.AddonDescription; import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.managers.IslandWorldManager; import world.bentobox.extramobs.ExtraMobsAddon; import world.bentobox.extramobs.config.Settings; +import world.bentobox.extramobs.listeners.mocks.ServerMocks; @RunWith(PowerMockRunner.class) public class MobsSpawnListenerTest { @@ -64,6 +66,7 @@ public class MobsSpawnListenerTest { @Before public void setUp() { + ServerMocks.newServer(); settings = new Settings(); when(addon.getSettings()).thenReturn(settings); @@ -93,6 +96,13 @@ public void setUp() { listener = new MobsSpawnListener(addon); } + @After + public void tearDown() { + ServerMocks.unsetBukkitServer(); + User.clearUsers(); + Mockito.framework().clearInlineMocks(); + } + // Test case for natural spawning of Zombified Piglin in the Nether @Test public void testNaturalSpawnZombifiedPiglinNether() { @@ -136,7 +146,6 @@ public void testFishSpawnDeepOcean() { when(event.getSpawnReason()).thenReturn(CreatureSpawnEvent.SpawnReason.NATURAL); when(event.getLocation()).thenReturn(location); when(world.getEnvironment()).thenReturn(World.Environment.NORMAL); - when(world.getBiome(anyInt(), anyInt(), anyInt())).thenReturn(Biome.DEEP_OCEAN); settings.setGuardianChance(1.1); // Set so that it will always spawn when(block.getType()).thenReturn(Material.WATER, Material.WATER, Material.WATER, Material.PRISMARINE); diff --git a/src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java b/src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java new file mode 100644 index 0000000..c8c12a5 --- /dev/null +++ b/src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java @@ -0,0 +1,118 @@ +package world.bentobox.extramobs.listeners.mocks; + +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Keyed; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.Server; +import org.bukkit.Tag; +import org.bukkit.UnsafeValues; +import org.eclipse.jdt.annotation.NonNull; + +public final class ServerMocks { + + public static @NonNull Server newServer() { + Server mock = mock(Server.class); + + Logger noOp = mock(Logger.class); + when(mock.getLogger()).thenReturn(noOp); + when(mock.isPrimaryThread()).thenReturn(true); + + // Unsafe + UnsafeValues unsafe = mock(UnsafeValues.class); + when(mock.getUnsafe()).thenReturn(unsafe); + + // Server must be available before tags can be mocked. + Bukkit.setServer(mock); + + // Bukkit has a lot of static constants referencing registry values. To initialize those, the + // registries must be able to be fetched before the classes are touched. + Map, Object> registers = new HashMap<>(); + + doAnswer(invocationGetRegistry -> registers.computeIfAbsent(invocationGetRegistry.getArgument(0), clazz -> { + Registry registry = mock(Registry.class); + Map cache = new HashMap<>(); + doAnswer(invocationGetEntry -> { + NamespacedKey key = invocationGetEntry.getArgument(0); + // Some classes (like BlockType and ItemType) have extra generics that will be + // erased during runtime calls. To ensure accurate typing, grab the constant's field. + // This approach also allows us to return null for unsupported keys. + Class constantClazz; + try { + //noinspection unchecked + constantClazz = (Class) clazz + .getField(key.getKey().toUpperCase(Locale.ROOT).replace('.', '_')).getType(); + } catch (ClassCastException e) { + throw new RuntimeException(e); + } catch (NoSuchFieldException e) { + return null; + } + + return cache.computeIfAbsent(key, key1 -> { + Keyed keyed = mock(constantClazz); + doReturn(key).when(keyed).getKey(); + return keyed; + }); + }).when(registry).get(notNull()); + return registry; + })).when(mock).getRegistry(notNull()); + + // Tags are dependent on registries, but use a different method. + // This will set up blank tags for each constant; all that needs to be done to render them + // functional is to re-mock Tag#getValues. + doAnswer(invocationGetTag -> { + Tag tag = mock(Tag.class); + doReturn(invocationGetTag.getArgument(1)).when(tag).getKey(); + doReturn(Set.of()).when(tag).getValues(); + doAnswer(invocationIsTagged -> { + Keyed keyed = invocationIsTagged.getArgument(0); + Class type = invocationGetTag.getArgument(2); + if (!type.isAssignableFrom(keyed.getClass())) { + return null; + } + // Since these are mocks, the exact instance might not be equal. Consider equal keys equal. + return tag.getValues().contains(keyed) + || tag.getValues().stream().anyMatch(value -> value.getKey().equals(keyed.getKey())); + }).when(tag).isTagged(notNull()); + return tag; + }).when(mock).getTag(notNull(), notNull(), notNull()); + + // Once the server is all set up, touch BlockType and ItemType to initialize. + // This prevents issues when trying to access dependent methods from a Material constant. + try { + Class.forName("org.bukkit.inventory.ItemType"); + Class.forName("org.bukkit.block.BlockType"); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + + return mock; + } + + public static void unsetBukkitServer() { + try { + Field server = Bukkit.class.getDeclaredField("server"); + server.setAccessible(true); + server.set(null, null); + } catch (NoSuchFieldException | IllegalArgumentException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + private ServerMocks() { + } + +} \ No newline at end of file From 70bf1a3472737e92ce3338e9216a50bbb644865a Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 29 May 2026 17:52:37 -0700 Subject: [PATCH 08/19] Add CLAUDE.md with build commands and architecture notes Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8a7eb28 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,35 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +ExtraMobs is a BentoBox addon (Bukkit/Spigot plugin) that re-skins certain natural mob spawns inside GameMode-managed worlds: Zombified Piglins → Blaze/Wither Skeleton in the Nether, Endermen → Shulkers in the End, and Fish → Guardians in deep-ocean overworld biomes. The addon does not alter Minecraft's spawn rules — it listens for natural spawns and conditionally cancels + re-spawns a different entity. + +## Build & test + +- `mvn clean package` — default goal; produces `target/ExtraMobs--LOCAL.jar`. +- `mvn test` — runs the test suite. +- `mvn test -Dtest=MobsSpawnListenerTest` — single test class. +- `mvn test -Dtest=MobsSpawnListenerTest#methodName` — single test method. +- Java 17 (`17` in pom.xml). Targets Spigot 1.21.3 and BentoBox 2.7.1-SNAPSHOT. +- Build versioning is driven by Maven profiles: `-LOCAL` by default, `-b` under Jenkins CI, and a clean release version when `GIT_BRANCH=origin/master`. Don't hand-edit version strings — change `build.version` in `pom.xml`. + +## Architecture + +Tiny codebase, three production classes: + +- `ExtraMobsAddon` (`src/main/java/world/bentobox/extramobs/`) — extends `world.bentobox.bentobox.api.addons.Addon`. In `onLoad()` it loads `Settings` via BentoBox's `Config<>` (auto-creates `config.yml` from `src/main/resources/`). In `onEnable()` it iterates `getAddonsManager().getGameModeAddons()`, sets `hooked=true` if any GameMode is not in `disabledGameModes`, and registers `MobsSpawnListener`. If nothing hooks, the addon disables itself. +- `config.Settings` — `ConfigObject` with `@StoreAt(filename="config.yml", path="addons/ExtraMobs")`. Field annotations (`@ConfigEntry`, `@ConfigComment`) drive both YAML parsing and the on-disk comment block; getters/setters are mandatory for the BentoBox config framework to bind values. +- `listeners.MobsSpawnListener` — single `@EventHandler(priority=HIGHEST, ignoreCancelled=true)` on `CreatureSpawnEvent`. Only `SpawnReason.NATURAL` events are considered. Flow: resolve the GameMode via `plugin.getIWM().getAddon(world)`, bail if disabled, then branch by entity type + environment (`isIslandNether` / `isIslandEnd` / `World.Environment.NORMAL` + biome). Each branch checks a "suitable block" predicate (nether brick / purpur / prismarine, with slab+stairs variants) and rolls against the configured chance. On a successful roll the event is cancelled and `world.spawnEntity()` summons the replacement. + +The "suitable location" helpers encode the design rule that drives the addon: replacement is gated on the player having built a themed structure. Changes to spawn rules almost always live in these predicates plus the dispatch branches in `onEntitySpawn`. + +## Testing notes + +- JUnit 4 + Mockito + PowerMock (`@RunWith(PowerMockRunner.class)`). Bukkit's static `Server`/`Registry`/`Tag` are stubbed via `listeners/mocks/ServerMocks.newServer()` — call this in `@Before` whenever a test touches Bukkit statics. The surefire plugin's long `--add-opens` argLine is required for PowerMock under Java 17; don't strip it. +- Jacoco excludes `**/*Names*` and `org/bukkit/Material*` to avoid synthetic-field / "Material too large to mock" failures. New tests that need to mock `Material` should rely on `ServerMocks` rather than reintroducing PowerMock static-mocking on `Material`. + +## Resources & packaging + +`src/main/resources/addon.yml` declares the addon to BentoBox (`main`, `softdepend` GameModes, icon). `config.yml` is filtered (`${version}` substitution), while `locales/*.yml` and `blueprints/*.{blu,json}` are copied unfiltered into the jar root under `./locales` and `./blueprints` — keep new resource directories consistent with this layout in `pom.xml` so BentoBox finds them at runtime. From 8958028c1f1afe3c6cf730b5e6feb838a0888bda Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 29 May 2026 18:00:01 -0700 Subject: [PATCH 09/19] Add Pladdon and plugin.yml Lets ExtraMobs load as a Paper plugin via the Pladdon entry point in addition to being loaded by BentoBox as an addon. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../bentobox/extramobs/ExtraMobsPladdon.java | 16 ++++++++++++++++ src/main/resources/plugin.yml | 9 +++++++++ 2 files changed, 25 insertions(+) create mode 100644 src/main/java/world/bentobox/extramobs/ExtraMobsPladdon.java create mode 100644 src/main/resources/plugin.yml diff --git a/src/main/java/world/bentobox/extramobs/ExtraMobsPladdon.java b/src/main/java/world/bentobox/extramobs/ExtraMobsPladdon.java new file mode 100644 index 0000000..79fb9e9 --- /dev/null +++ b/src/main/java/world/bentobox/extramobs/ExtraMobsPladdon.java @@ -0,0 +1,16 @@ +package world.bentobox.extramobs; + +import world.bentobox.bentobox.api.addons.Addon; +import world.bentobox.bentobox.api.addons.Pladdon; + +public class ExtraMobsPladdon extends Pladdon { + private Addon addon; + + @Override + public Addon getAddon() { + if (addon == null) { + addon = new ExtraMobsAddon(); + } + return addon; + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..334a57e --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,9 @@ +name: BentoBox-ExtraMobs +main: world.bentobox.extramobs.ExtraMobsPladdon +version: ${project.version}${build.number} +api-version: "1.21" + +authors: [BONNe] +contributors: ["The BentoBoxWorld Community"] +website: https://bentobox.world +description: ${project.description} From 4949cb20c9034d69bf6c1455fe2106e65ec2e417 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 29 May 2026 18:00:08 -0700 Subject: [PATCH 10/19] Update to Java 21, Paper 1.21.11, BentoBox 3.14.0 Replaces Spigot with the Paper API, bumps Java to 21, BentoBox to 3.14.0-SNAPSHOT, and swaps the JUnit 4 + PowerMock + Mockito 3 test stack for JUnit 5 + MockBukkit + Mockito 5. Also updates compiler, surefire, and shade plugins to versions that support Java 21 class files. PowerMock was the previous test framework but it cannot instrument modern class files, so the suite no longer runs without this swap. Co-Authored-By: Claude Opus 4.7 (1M context) --- pom.xml | 131 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 77 insertions(+), 54 deletions(-) diff --git a/pom.xml b/pom.xml index 5331fcd..cbb6444 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,7 @@ GitHub https://github.com/BentoBoxWorld/ExtraMobs/issues - + bentoboxworld @@ -39,11 +39,14 @@ UTF-8 UTF-8 - 17 - 2.0.9 + 21 - 1.21.3-R0.1-SNAPSHOT - 2.7.1-SNAPSHOT + 1.21.11-R0.1-SNAPSHOT + 3.14.0-SNAPSHOT + + 5.10.2 + 5.11.0 + 4.110.0 ${build.version}-SNAPSHOT @@ -60,7 +63,7 @@ - ci @@ -74,13 +77,13 @@ - - - - master @@ -100,66 +103,84 @@ - spigot-repo - https://hub.spigotmc.org/nexus/content/repositories/snapshots + papermc + https://repo.papermc.io/repository/maven-public/ bentoboxworld https://repo.codemc.org/repository/bentoboxworld/ + + codemc-repo + https://repo.codemc.org/repository/maven-public/ + codemc https://repo.codemc.org/repository/maven-snapshots/ - codemc-repo - https://repo.codemc.org/repository/maven-public/ + jitpack.io + https://jitpack.io - + - org.spigotmc - spigot-api - ${spigot.version} + io.papermc.paper + paper-api + ${paper.version} provided - - org.mockito - mockito-core - 3.11.1 + world.bentobox + bentobox + ${bentobox.version} + provided + + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + ${mock-bukkit.version} test + - org.powermock - powermock-module-junit4 - ${powermock.version} + org.junit.jupiter + junit-jupiter-api + ${junit.version} test - org.powermock - powermock-api-mockito2 - ${powermock.version} + org.junit.jupiter + junit-jupiter-engine + ${junit.version} test + - world.bentobox - bentobox - ${bentobox.version} - provided + org.mockito + mockito-core + ${mockito.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test - - ${project.name}-${revision}${build.number} @@ -207,43 +228,40 @@ org.apache.maven.plugins maven-compiler-plugin - 3.8.1 + 3.15.0 ${java.version} + true org.apache.maven.plugins maven-surefire-plugin - 3.1.2 - + 3.5.2 + + **/*Test.java + **/*Test?.java + **/*Test??.java + - ${argLine} --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.math=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED - --add-opens - java.base/java.util.stream=ALL-UNNAMED + --add-opens java.base/java.util.stream=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED - --add-opens - java.base/java.util.regex=ALL-UNNAMED - --add-opens - java.base/java.nio.channels.spi=ALL-UNNAMED + --add-opens java.base/java.util.regex=ALL-UNNAMED + --add-opens java.base/java.nio.channels.spi=ALL-UNNAMED --add-opens java.base/sun.nio.ch=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED - --add-opens - java.base/java.util.concurrent=ALL-UNNAMED + --add-opens java.base/java.util.concurrent=ALL-UNNAMED --add-opens java.base/sun.nio.fs=ALL-UNNAMED --add-opens java.base/sun.nio.cs=ALL-UNNAMED --add-opens java.base/java.nio.file=ALL-UNNAMED - --add-opens - java.base/java.nio.charset=ALL-UNNAMED - --add-opens - java.base/java.lang.reflect=ALL-UNNAMED - --add-opens - java.logging/java.util.logging=ALL-UNNAMED + --add-opens java.base/java.nio.charset=ALL-UNNAMED + --add-opens java.base/java.lang.reflect=ALL-UNNAMED + --add-opens java.logging/java.util.logging=ALL-UNNAMED --add-opens java.base/java.lang.ref=ALL-UNNAMED --add-opens java.base/java.util.jar=ALL-UNNAMED --add-opens java.base/java.util.zip=ALL-UNNAMED @@ -262,8 +280,8 @@ false -Xdoclint:none - ${java.home}/bin/javadoc + 21 @@ -298,6 +316,11 @@ maven-deploy-plugin 2.8.2 + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + org.jacoco jacoco-maven-plugin @@ -305,7 +328,7 @@ true - **/*Names* From adac15bb2b16f6f640d29b7114e531e9ee33554b Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 29 May 2026 18:00:19 -0700 Subject: [PATCH 11/19] Add JUnit 5 test suite with MockBukkit Rewrites MobsSpawnListenerTest on JUnit 5 + MockBukkit + Mockito 5 and adds ExtraMobsAddonTest covering the addon lifecycle. Introduces CommonTestSetup, TestWorldSettings, and WhiteBox following the CaveBlock / DimensionalTrees pattern so future tests can extend CommonTestSetup and inherit a real MockBukkit server plus a stubbed BentoBox singleton. 32 tests covering guard clauses, the Nether/End/Overworld replacement branches, and the suitable-block predicates. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../bentobox/extramobs/CommonTestSetup.java | 221 ++++++++++ .../extramobs/ExtraMobsAddonTest.java | 168 ++++++++ .../bentobox/extramobs/TestWorldSettings.java | 345 +++++++++++++++ .../world/bentobox/extramobs/WhiteBox.java | 13 + .../listeners/MobsSpawnListenerTest.java | 392 ++++++++++++++---- .../listeners/mocks/ServerMocks.java | 118 ------ 6 files changed, 1057 insertions(+), 200 deletions(-) create mode 100644 src/test/java/world/bentobox/extramobs/CommonTestSetup.java create mode 100644 src/test/java/world/bentobox/extramobs/ExtraMobsAddonTest.java create mode 100644 src/test/java/world/bentobox/extramobs/TestWorldSettings.java create mode 100644 src/test/java/world/bentobox/extramobs/WhiteBox.java delete mode 100644 src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java diff --git a/src/test/java/world/bentobox/extramobs/CommonTestSetup.java b/src/test/java/world/bentobox/extramobs/CommonTestSetup.java new file mode 100644 index 0000000..f9710a0 --- /dev/null +++ b/src/test/java/world/bentobox/extramobs/CommonTestSetup.java @@ -0,0 +1,221 @@ +package world.bentobox.extramobs; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.entity.Player.Spigot; +import org.bukkit.inventory.ItemFactory; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.PluginManager; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.stubbing.Answer; + +import com.google.common.collect.ImmutableSet; + +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.configuration.WorldSettings; +import world.bentobox.bentobox.api.user.Notifier; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.database.objects.Players; +import world.bentobox.bentobox.managers.BlueprintsManager; +import world.bentobox.bentobox.managers.FlagsManager; +import world.bentobox.bentobox.managers.HooksManager; +import world.bentobox.bentobox.managers.IslandWorldManager; +import world.bentobox.bentobox.managers.IslandsManager; +import world.bentobox.bentobox.managers.LocalesManager; +import world.bentobox.bentobox.managers.PlaceholdersManager; +import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.bentobox.util.Util; + +/** + * Common test setup for ExtraMobs tests. Call super.setUp() in subclass @BeforeEach. + */ +public abstract class CommonTestSetup { + + protected UUID uuid = UUID.randomUUID(); + + @Mock + protected Player mockPlayer; + @Mock + protected PluginManager pim; + @Mock + protected ItemFactory itemFactory; + @Mock + protected Location location; + @Mock + protected World world; + @Mock + protected IslandWorldManager iwm; + @Mock + protected IslandsManager im; + @Mock + protected Island island; + @Mock + protected BentoBox plugin; + @Mock + protected PlayerInventory inv; + @Mock + protected Notifier notifier; + @Mock + protected FlagsManager fm; + @Mock + protected Spigot spigot; + @Mock + protected HooksManager hooksManager; + @Mock + protected BlueprintsManager bm; + @Mock + protected BukkitScheduler sch; + @Mock + protected LocalesManager lm; + @Mock + protected PlaceholdersManager phm; + + protected ServerMock server; + protected MockedStatic mockedBukkit; + protected MockedStatic mockedUtil; + protected AutoCloseable closeable; + + @BeforeEach + @SuppressWarnings("java:S1130") + public void setUp() throws Exception { + closeable = MockitoAnnotations.openMocks(this); + server = MockBukkit.mock(); + + // Inject BentoBox singleton + WhiteBox.setInternalState(BentoBox.class, "instance", plugin); + + // Force Tag static fields to initialise under the real server + @SuppressWarnings("unused") + var unusedTagRef = org.bukkit.Tag.LEAVES; + + // Static Bukkit mock + mockedBukkit = Mockito.mockStatic(Bukkit.class, Mockito.RETURNS_DEEP_STUBS); + mockedBukkit.when(Bukkit::getMinecraftVersion).thenReturn("1.21.10"); + mockedBukkit.when(Bukkit::getBukkitVersion).thenReturn(""); + mockedBukkit.when(Bukkit::getPluginManager).thenReturn(pim); + mockedBukkit.when(Bukkit::getItemFactory).thenReturn(itemFactory); + mockedBukkit.when(Bukkit::getServer).thenReturn(server); + mockedBukkit.when(Bukkit::getScheduler).thenReturn(sch); + + // Location + when(location.getWorld()).thenReturn(world); + when(location.getBlockX()).thenReturn(0); + when(location.getBlockY()).thenReturn(0); + when(location.getBlockZ()).thenReturn(0); + when(location.toVector()).thenReturn(new Vector(0, 0, 0)); + when(location.clone()).thenReturn(location); + + // PlayersManager + PlayersManager pm = mock(PlayersManager.class); + when(plugin.getPlayers()).thenReturn(pm); + Players players = mock(Players.class); + when(players.getMetaData()).thenReturn(Optional.empty()); + when(pm.getPlayer(any(UUID.class))).thenReturn(players); + + // Player + when(mockPlayer.getUniqueId()).thenReturn(uuid); + when(mockPlayer.getLocation()).thenReturn(location); + when(mockPlayer.getWorld()).thenReturn(world); + when(mockPlayer.getName()).thenReturn("tastybento"); + when(mockPlayer.getInventory()).thenReturn(inv); + when(mockPlayer.spigot()).thenReturn(spigot); + when(mockPlayer.getType()).thenReturn(EntityType.PLAYER); + + User.setPlugin(plugin); + User.clearUsers(); + User.getInstance(mockPlayer); + + // IWM + when(plugin.getIWM()).thenReturn(iwm); + when(iwm.inWorld(any(Location.class))).thenReturn(true); + when(iwm.inWorld(any(World.class))).thenReturn(true); + when(iwm.getFriendlyName(any())).thenReturn("ExtraMobs"); + when(iwm.getAddon(any())).thenReturn(Optional.empty()); + + // WorldSettings + WorldSettings worldSet = new TestWorldSettings(); + when(iwm.getWorldSettings(any())).thenReturn(worldSet); + + // IslandsManager + when(plugin.getIslands()).thenReturn(im); + when(im.getProtectedIslandAt(any())).thenReturn(Optional.of(island)); + when(island.isAllowed(any())).thenReturn(false); + when(island.isAllowed(any(User.class), any())).thenReturn(false); + when(island.getOwner()).thenReturn(uuid); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid)); + + // Locales & Placeholders + when(lm.get(any(), any())).thenAnswer((Answer) invocation -> invocation.getArgument(1, String.class)); + when(plugin.getPlaceholdersManager()).thenReturn(phm); + when(phm.replacePlaceholders(any(), any())).thenAnswer((Answer) invocation -> invocation.getArgument(1, String.class)); + when(plugin.getLocalesManager()).thenReturn(lm); + + // Notifier + when(plugin.getNotifier()).thenReturn(notifier); + + // Logger — Addon.getLogger() delegates to plugin.getLogger() + when(plugin.getLogger()).thenReturn(Logger.getLogger("ExtraMobs-test")); + + // BentoBox settings (fake players feature) + world.bentobox.bentobox.Settings settings = new world.bentobox.bentobox.Settings(); + when(plugin.getSettings()).thenReturn(settings); + + // Util static mock + mockedUtil = Mockito.mockStatic(Util.class, Mockito.CALLS_REAL_METHODS); + mockedUtil.when(() -> Util.getWorld(any())).thenReturn(mock(World.class)); + Util.setPlugin(plugin); + mockedUtil.when(() -> Util.findFirstMatchingEnum(any(), any())).thenCallRealMethod(); + + // Hooks + when(hooksManager.getHook(anyString())).thenReturn(Optional.empty()); + when(plugin.getHooks()).thenReturn(hooksManager); + + // BlueprintsManager + when(plugin.getBlueprintsManager()).thenReturn(bm); + } + + @AfterEach + public void tearDown() throws Exception { + mockedBukkit.closeOnDemand(); + mockedUtil.closeOnDemand(); + closeable.close(); + MockBukkit.unmock(); + User.clearUsers(); + Mockito.framework().clearInlineMocks(); + deleteAll(new File("database")); + deleteAll(new File("database_backup")); + } + + protected static void deleteAll(File file) throws IOException { + if (file.exists()) { + Files.walk(file.toPath()).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete); + } + } +} diff --git a/src/test/java/world/bentobox/extramobs/ExtraMobsAddonTest.java b/src/test/java/world/bentobox/extramobs/ExtraMobsAddonTest.java new file mode 100644 index 0000000..e9edb2d --- /dev/null +++ b/src/test/java/world/bentobox/extramobs/ExtraMobsAddonTest.java @@ -0,0 +1,168 @@ +package world.bentobox.extramobs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import world.bentobox.bentobox.api.addons.AddonDescription; +import world.bentobox.bentobox.database.AbstractDatabaseHandler; +import world.bentobox.bentobox.database.DatabaseSetup; +import world.bentobox.bentobox.managers.AddonsManager; +import world.bentobox.bentobox.managers.CommandsManager; +import world.bentobox.extramobs.config.Settings; + +/** + * Tests for {@link ExtraMobsAddon}. + */ +class ExtraMobsAddonTest extends CommonTestSetup { + + private static final String CONFIG_YML = """ + disabled-gamemodes: [] + nether-chances: + wither-skeleton: 0.01 + blaze: 0.1 + end-chances: + shulker: 0.1 + overworld-chance: + guardian: 0.1 + """; + + @Mock + private AddonsManager am; + + private ExtraMobsAddon addon; + private MockedStatic mockDb; + + @SuppressWarnings("unchecked") + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + + // Database mock + AbstractDatabaseHandler h = mock(AbstractDatabaseHandler.class); + mockDb = Mockito.mockStatic(DatabaseSetup.class); + DatabaseSetup dbSetup = mock(DatabaseSetup.class); + mockDb.when(DatabaseSetup::getDatabase).thenReturn(dbSetup); + when(dbSetup.getHandler(any())).thenReturn(h); + when(h.saveObject(any())).thenReturn(CompletableFuture.completedFuture(true)); + + // CommandsManager + CommandsManager cm = mock(CommandsManager.class); + when(plugin.getCommandsManager()).thenReturn(cm); + + // AddonsManager — no GameMode addons hooked by default + when(plugin.getAddonsManager()).thenReturn(am); + when(am.getGameModeAddons()).thenReturn(Collections.emptyList()); + + // FlagsManager + when(plugin.getFlagsManager()).thenReturn(fm); + when(fm.getFlags()).thenReturn(Collections.emptyList()); + + addon = new ExtraMobsAddon(); + File jFile = new File("addon.jar"); + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jFile))) { + addJarEntry(jos, "config.yml", CONFIG_YML); + } + File dataFolder = new File("addons/ExtraMobs"); + addon.setDataFolder(dataFolder); + addon.setFile(jFile); + AddonDescription desc = new AddonDescription.Builder("bentobox", "ExtraMobs", "1.0.0") + .description("test").authors("BONNe").build(); + addon.setDescription(desc); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + if (mockDb != null) { + mockDb.closeOnDemand(); + } + super.tearDown(); + new File("addon.jar").delete(); + deleteAll(new File("addons")); + } + + private static void addJarEntry(JarOutputStream jos, String name, String content) throws Exception { + JarEntry entry = new JarEntry(name); + jos.putNextEntry(entry); + jos.write(content.getBytes(StandardCharsets.UTF_8)); + jos.closeEntry(); + } + + @Test + void testGetSettingsNullBeforeLoad() { + assertNull(addon.getSettings()); + } + + @Test + void testIsHookedFalseBeforeEnable() { + assertFalse(addon.isHooked()); + } + + @Test + void testOnLoad() { + addon.onLoad(); + assertNotNull(addon.getSettings()); + } + + @Test + void testOnLoadSettingsDefaults() { + addon.onLoad(); + Settings s = addon.getSettings(); + assertNotNull(s); + assertEquals(0.01, s.getWitherSkeletonChance(), 1e-9); + assertEquals(0.1, s.getBlazeChance(), 1e-9); + assertEquals(0.1, s.getShulkerChance(), 1e-9); + assertEquals(0.1, s.getGuardianChance(), 1e-9); + assertNotNull(s.getDisabledGameModes()); + assertEquals(0, s.getDisabledGameModes().size()); + } + + @Test + void testOnEnableWithoutGameModeDisablesAddon() { + addon.onLoad(); + addon.onEnable(); + // No GameMode addons hooked → addon never sets hooked = true + assertFalse(addon.isHooked()); + } + + @Test + void testOnDisable() { + addon.onDisable(); + assertNotNull(addon); + } + + @Test + void testOnReload() { + addon.onLoad(); + addon.onReload(); + assertNotNull(addon.getSettings()); + } + + @Test + void testOnReloadPreservesSettings() { + addon.onLoad(); + addon.onReload(); + assertEquals(0.01, addon.getSettings().getWitherSkeletonChance(), 1e-9); + } +} diff --git a/src/test/java/world/bentobox/extramobs/TestWorldSettings.java b/src/test/java/world/bentobox/extramobs/TestWorldSettings.java new file mode 100644 index 0000000..98c27c7 --- /dev/null +++ b/src/test/java/world/bentobox/extramobs/TestWorldSettings.java @@ -0,0 +1,345 @@ +package world.bentobox.extramobs; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.bukkit.Difficulty; +import org.bukkit.GameMode; +import org.bukkit.entity.EntityType; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.bentobox.api.configuration.WorldSettings; +import world.bentobox.bentobox.api.flags.Flag; + +/** + * Minimal WorldSettings implementation for use in tests. + */ +public class TestWorldSettings implements WorldSettings { + + private long epoch; + + @Override + public GameMode getDefaultGameMode() { + return GameMode.SURVIVAL; + } + + @SuppressWarnings("removal") + @Override + public Map getDefaultIslandFlags() { + return Collections.emptyMap(); + } + + @SuppressWarnings("removal") + @Override + public Map getDefaultIslandSettings() { + return Collections.emptyMap(); + } + + @Override + public Difficulty getDifficulty() { + return Difficulty.NORMAL; + } + + @Override + public void setDifficulty(Difficulty difficulty) { + // unused + } + + @Override + public String getFriendlyName() { + return "ExtraMobs"; + } + + @Override + public int getIslandDistance() { + return 0; + } + + @Override + public int getIslandHeight() { + return 0; + } + + @Override + public int getIslandProtectionRange() { + return 0; + } + + @Override + public int getIslandStartX() { + return 0; + } + + @Override + public int getIslandStartZ() { + return 0; + } + + @Override + public int getIslandXOffset() { + return 0; + } + + @Override + public int getIslandZOffset() { + return 0; + } + + @Override + public List getIvSettings() { + return Collections.emptyList(); + } + + @Override + public int getMaxHomes() { + return 3; + } + + @Override + public int getMaxIslands() { + return 0; + } + + @Override + public int getMaxTeamSize() { + return 4; + } + + @Override + public int getNetherSpawnRadius() { + return 10; + } + + @Override + public String getPermissionPrefix() { + return "extramobs."; + } + + @Override + public Set getRemoveMobsWhitelist() { + return Collections.emptySet(); + } + + @Override + public int getSeaHeight() { + return 0; + } + + @Override + public List getHiddenFlags() { + return Collections.emptyList(); + } + + @Override + public List getVisitorBannedCommands() { + return Collections.emptyList(); + } + + @Override + public Map getWorldFlags() { + return new HashMap<>(); + } + + @Override + public String getWorldName() { + return "extramobs-world"; + } + + @Override + public boolean isDragonSpawn() { + return false; + } + + @Override + public boolean isEndGenerate() { + return true; + } + + @Override + public boolean isEndIslands() { + return true; + } + + @Override + public boolean isNetherGenerate() { + return true; + } + + @Override + public boolean isNetherIslands() { + return true; + } + + @Override + public boolean isOnJoinResetEnderChest() { + return false; + } + + @Override + public boolean isOnJoinResetInventory() { + return false; + } + + @Override + public boolean isOnJoinResetMoney() { + return false; + } + + @Override + public boolean isOnJoinResetHealth() { + return false; + } + + @Override + public boolean isOnJoinResetHunger() { + return false; + } + + @Override + public boolean isOnJoinResetXP() { + return false; + } + + @Override + public @NonNull List getOnJoinCommands() { + return Collections.emptyList(); + } + + @Override + public boolean isOnLeaveResetEnderChest() { + return false; + } + + @Override + public boolean isOnLeaveResetInventory() { + return false; + } + + @Override + public boolean isOnLeaveResetMoney() { + return false; + } + + @Override + public boolean isOnLeaveResetHealth() { + return false; + } + + @Override + public boolean isOnLeaveResetHunger() { + return false; + } + + @Override + public boolean isOnLeaveResetXP() { + return false; + } + + @Override + public @NonNull List getOnLeaveCommands() { + return Collections.emptyList(); + } + + @Override + public boolean isUseOwnGenerator() { + return false; + } + + @Override + public boolean isWaterUnsafe() { + return false; + } + + @Override + public List getGeoLimitSettings() { + return Collections.emptyList(); + } + + @Override + public int getResetLimit() { + return 0; + } + + @Override + public long getResetEpoch() { + return epoch; + } + + @Override + public void setResetEpoch(long timestamp) { + this.epoch = timestamp; + } + + @Override + public boolean isTeamJoinDeathReset() { + return false; + } + + @Override + public int getDeathsMax() { + return 0; + } + + @Override + public boolean isDeathsCounted() { + return true; + } + + @Override + public boolean isDeathsResetOnNewIsland() { + return true; + } + + @Override + public boolean isAllowSetHomeInNether() { + return false; + } + + @Override + public boolean isAllowSetHomeInTheEnd() { + return false; + } + + @Override + public boolean isRequireConfirmationToSetHomeInNether() { + return false; + } + + @Override + public boolean isRequireConfirmationToSetHomeInTheEnd() { + return false; + } + + @Override + public int getBanLimit() { + return 10; + } + + @Override + public boolean isLeaversLoseReset() { + return true; + } + + @Override + public boolean isKickedKeepInventory() { + return true; + } + + @Override + public boolean isCreateIslandOnFirstLoginEnabled() { + return false; + } + + @Override + public int getCreateIslandOnFirstLoginDelay() { + return 0; + } + + @Override + public boolean isCreateIslandOnFirstLoginAbortOnLogout() { + return false; + } +} diff --git a/src/test/java/world/bentobox/extramobs/WhiteBox.java b/src/test/java/world/bentobox/extramobs/WhiteBox.java new file mode 100644 index 0000000..fdd41c2 --- /dev/null +++ b/src/test/java/world/bentobox/extramobs/WhiteBox.java @@ -0,0 +1,13 @@ +package world.bentobox.extramobs; + +public class WhiteBox { + public static void setInternalState(Class targetClass, String fieldName, Object value) { + try { + java.lang.reflect.Field field = targetClass.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(null, value); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("Failed to set static field '" + fieldName + "' on class " + targetClass.getName(), e); + } + } +} diff --git a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java index dac2c30..587e70c 100644 --- a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java +++ b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java @@ -1,13 +1,15 @@ package world.bentobox.extramobs.listeners; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.Collections; import java.util.Optional; +import java.util.Set; import org.bukkit.Location; import org.bukkit.Material; @@ -16,155 +18,381 @@ import org.bukkit.block.Block; import org.bukkit.entity.EntityType; import org.bukkit.entity.Fish; +import org.bukkit.entity.LivingEntity; import org.bukkit.event.entity.CreatureSpawnEvent; -import org.eclipse.jdt.annotation.NonNull; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mockito.Mock; -import org.mockito.Mockito; -import org.powermock.modules.junit4.PowerMockRunner; -import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.addons.AddonDescription; import world.bentobox.bentobox.api.addons.GameModeAddon; -import world.bentobox.bentobox.api.user.User; -import world.bentobox.bentobox.managers.IslandWorldManager; +import world.bentobox.extramobs.CommonTestSetup; import world.bentobox.extramobs.ExtraMobsAddon; import world.bentobox.extramobs.config.Settings; -import world.bentobox.extramobs.listeners.mocks.ServerMocks; -@RunWith(PowerMockRunner.class) -public class MobsSpawnListenerTest { +/** + * Tests for {@link MobsSpawnListener}. + */ +class MobsSpawnListenerTest extends CommonTestSetup { @Mock private ExtraMobsAddon addon; - + @Mock + private Settings settings; @Mock private CreatureSpawnEvent event; - @Mock - private World world; + private Block standingBlock; + @Mock + private Block blockBelow; + @Mock + private GameModeAddon gameModeAddon; private MobsSpawnListener listener; - private Settings settings; + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); - @Mock - private BentoBox plugin; + when(addon.getPlugin()).thenReturn(plugin); + when(addon.getSettings()).thenReturn(settings); + when(settings.getDisabledGameModes()).thenReturn(Collections.emptySet()); + when(settings.getWitherSkeletonChance()).thenReturn(0.0); + when(settings.getBlazeChance()).thenReturn(0.0); + when(settings.getShulkerChance()).thenReturn(0.0); + when(settings.getGuardianChance()).thenReturn(0.0); + + // GameMode resolved by default + AddonDescription desc = new AddonDescription.Builder("main.Class", "BSkyBlock", "1.0").build(); + when(gameModeAddon.getDescription()).thenReturn(desc); + when(iwm.getAddon(world)).thenReturn(Optional.of(gameModeAddon)); + + // Event basics + when(event.getLocation()).thenReturn(location); + when(event.getSpawnReason()).thenReturn(CreatureSpawnEvent.SpawnReason.NATURAL); - @Mock - private IslandWorldManager iwm; + // Location resolves to mocked world + the standing block + when(location.getBlock()).thenReturn(standingBlock); + when(standingBlock.getRelative(org.bukkit.block.BlockFace.DOWN)).thenReturn(blockBelow); + when(blockBelow.getType()).thenReturn(Material.AIR); - @Mock - private GameModeAddon gma; + listener = new MobsSpawnListener(addon); + } - @Mock - private Location location; + // ── Guard clauses ────────────────────────────────────────────────────── - @Mock - private Block block; + @Test + void testNonNaturalSpawnIgnored() { + when(event.getSpawnReason()).thenReturn(CreatureSpawnEvent.SpawnReason.SPAWNER); + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); + when(iwm.isIslandNether(world)).thenReturn(true); + when(settings.getWitherSkeletonChance()).thenReturn(1.0); - @Before - public void setUp() { - ServerMocks.newServer(); - settings = new Settings(); - when(addon.getSettings()).thenReturn(settings); + listener.onEntitySpawn(event); - when(addon.getPlugin()).thenReturn(plugin); + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + verify(event, never()).setCancelled(true); + } - when(plugin.getIWM()).thenReturn(iwm); + @Test + void testNoGameModeIgnored() { + when(iwm.getAddon(world)).thenReturn(Optional.empty()); + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); + when(iwm.isIslandNether(world)).thenReturn(true); + when(settings.getWitherSkeletonChance()).thenReturn(1.0); - when(iwm.getAddon(world)).thenReturn(Optional.of(gma)); + listener.onEntitySpawn(event); - when(iwm.isIslandEnd(world)).thenReturn(true); + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + } + + @Test + void testDisabledGameModeIgnored() { + when(settings.getDisabledGameModes()).thenReturn(Set.of("BSkyBlock")); + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); when(iwm.isIslandNether(world)).thenReturn(true); + when(settings.getWitherSkeletonChance()).thenReturn(1.0); - @NonNull - AddonDescription desc = new AddonDescription.Builder("main", "bskyblock", "1.0.0").build(); + listener.onEntitySpawn(event); - when(gma.getDescription()).thenReturn(desc); + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + } - // Location - when(location.getBlock()).thenReturn(block); - when(location.getWorld()).thenReturn(world); + // ── Nether: piglin → wither skeleton / blaze ─────────────────────────── - when(block.getRelative(any())).thenReturn(block); + @Test + void testZombifiedPiglinOnNetherBrickReplacedWithWitherSkeleton() { + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); + when(iwm.isIslandNether(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.NETHER_BRICKS); + when(settings.getWitherSkeletonChance()).thenReturn(1.0); - when(block.getType()).thenReturn(Material.STONE); + listener.onEntitySpawn(event); - // Initialize mocks and the class to test - listener = new MobsSpawnListener(addon); + verify(world).spawnEntity(location, EntityType.WITHER_SKELETON); + verify(event).setCancelled(true); } - @After - public void tearDown() { - ServerMocks.unsetBukkitServer(); - User.clearUsers(); - Mockito.framework().clearInlineMocks(); + @Test + void testPiglinOnNetherBrickReplacedWithWitherSkeleton() { + when(event.getEntityType()).thenReturn(EntityType.PIGLIN); + when(iwm.isIslandNether(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.NETHER_BRICKS); + when(settings.getWitherSkeletonChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world).spawnEntity(location, EntityType.WITHER_SKELETON); + verify(event).setCancelled(true); } - // Test case for natural spawning of Zombified Piglin in the Nether @Test - public void testNaturalSpawnZombifiedPiglinNether() { + void testPiglinOnNetherBrickSlabReplacedWithBlazeWhenWitherFails() { when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); - when(event.getSpawnReason()).thenReturn(CreatureSpawnEvent.SpawnReason.NATURAL); - when(event.getLocation()).thenReturn(location); - when(world.getEnvironment()).thenReturn(World.Environment.NETHER); - when(addon.getPlugin().getIWM().isIslandNether(world)).thenReturn(true); - settings.setWitherSkeletonChance(1.1); // Set so that it will always spawn - when(block.getType()).thenReturn(Material.NETHER_BRICKS); + when(iwm.isIslandNether(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.NETHER_BRICK_SLAB); + when(settings.getWitherSkeletonChance()).thenReturn(0.0); + when(settings.getBlazeChance()).thenReturn(1.0); listener.onEntitySpawn(event); + verify(world).spawnEntity(location, EntityType.BLAZE); verify(event).setCancelled(true); - // Additional verifications can be added to check if the correct entity was spawned } - // Test case for natural spawning of Enderman in the End @Test - public void testNaturalSpawnEndermanEnd() { + void testPiglinOnNetherBrickStairsAcceptedForReplacement() { + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); + when(iwm.isIslandNether(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.NETHER_BRICK_STAIRS); + when(settings.getWitherSkeletonChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world).spawnEntity(location, EntityType.WITHER_SKELETON); + } + + @Test + void testPiglinOnNonNetherBrickNotReplaced() { + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); + when(iwm.isIslandNether(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.STONE); + when(settings.getWitherSkeletonChance()).thenReturn(1.0); + when(settings.getBlazeChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + verify(event, never()).setCancelled(true); + } + + @Test + void testPiglinNotInNetherSkipsBranch() { + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); + when(iwm.isIslandNether(world)).thenReturn(false); + when(blockBelow.getType()).thenReturn(Material.NETHER_BRICKS); + when(settings.getWitherSkeletonChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + } + + @Test + void testPiglinChanceZeroNoReplacement() { + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); + when(iwm.isIslandNether(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.NETHER_BRICKS); + // both chances are 0.0 from setUp + + listener.onEntitySpawn(event); + + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + verify(event, never()).setCancelled(true); + } + + // ── End: enderman → shulker ──────────────────────────────────────────── + + @Test + void testEndermanOnPurpurReplacedWithShulker() { when(event.getEntityType()).thenReturn(EntityType.ENDERMAN); - when(event.getSpawnReason()).thenReturn(CreatureSpawnEvent.SpawnReason.NATURAL); - when(event.getLocation()).thenReturn(location); - when(world.getEnvironment()).thenReturn(World.Environment.THE_END); - when(addon.getPlugin().getIWM().isIslandEnd(world)).thenReturn(true); - settings.setShulkerChance(1.1); // Set so that it will always spawn - when(block.getType()).thenReturn(Material.PURPUR_BLOCK); + when(iwm.isIslandEnd(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.PURPUR_BLOCK); + when(settings.getShulkerChance()).thenReturn(1.0); listener.onEntitySpawn(event); + verify(world).spawnEntity(location, EntityType.SHULKER); verify(event).setCancelled(true); - // Additional verifications can be added to check if the correct entity was spawned } - // Test case for spawning of Fish in Deep Ocean biome @Test - public void testFishSpawnDeepOcean() { + void testEndermanOnPurpurSlabAccepted() { + when(event.getEntityType()).thenReturn(EntityType.ENDERMAN); + when(iwm.isIslandEnd(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.PURPUR_SLAB); + when(settings.getShulkerChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world).spawnEntity(location, EntityType.SHULKER); + } + + @Test + void testEndermanOnPurpurStairsAccepted() { + when(event.getEntityType()).thenReturn(EntityType.ENDERMAN); + when(iwm.isIslandEnd(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.PURPUR_STAIRS); + when(settings.getShulkerChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world).spawnEntity(location, EntityType.SHULKER); + } + + @Test + void testEndermanOnNonPurpurNotReplaced() { + when(event.getEntityType()).thenReturn(EntityType.ENDERMAN); + when(iwm.isIslandEnd(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.END_STONE); + when(settings.getShulkerChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + } + + @Test + void testEndermanNotInEndSkipsBranch() { + when(event.getEntityType()).thenReturn(EntityType.ENDERMAN); + when(iwm.isIslandEnd(world)).thenReturn(false); + when(blockBelow.getType()).thenReturn(Material.PURPUR_BLOCK); + when(settings.getShulkerChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + } + + @Test + void testEndermanChanceZeroNoReplacement() { + when(event.getEntityType()).thenReturn(EntityType.ENDERMAN); + when(iwm.isIslandEnd(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.PURPUR_BLOCK); + + listener.onEntitySpawn(event); + + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + } + + // ── Overworld: fish → guardian ───────────────────────────────────────── + + private Block prepareWaterColumnTopped(Material topMaterial) { + Block water = mock(Block.class); + when(water.getType()).thenReturn(Material.WATER); + Block top = mock(Block.class); + when(top.getType()).thenReturn(topMaterial); + when(water.getRelative(org.bukkit.block.BlockFace.UP)).thenReturn(top); + when(location.getBlock()).thenReturn(water); + return top; + } + + private void prepareFishEvent(Biome biome) { Fish fish = mock(Fish.class); when(event.getEntity()).thenReturn(fish); - when(event.getEntityType()).thenReturn(EntityType.TROPICAL_FISH); - when(event.getSpawnReason()).thenReturn(CreatureSpawnEvent.SpawnReason.NATURAL); - when(event.getLocation()).thenReturn(location); + when(event.getEntityType()).thenReturn(EntityType.COD); when(world.getEnvironment()).thenReturn(World.Environment.NORMAL); - settings.setGuardianChance(1.1); // Set so that it will always spawn - when(block.getType()).thenReturn(Material.WATER, Material.WATER, Material.WATER, Material.PRISMARINE); + when(world.getBiome(0, 0, 0)).thenReturn(biome); + } + + @Test + void testFishInDeepOceanOverPrismarineReplacedWithGuardian() { + prepareFishEvent(Biome.DEEP_OCEAN); + prepareWaterColumnTopped(Material.PRISMARINE); + when(settings.getGuardianChance()).thenReturn(1.0); listener.onEntitySpawn(event); + verify(world).spawnEntity(location, EntityType.GUARDIAN); verify(event).setCancelled(true); - // Additional verifications for guardian spawning } - // Test case for non-natural spawning @Test - public void testNonNaturalSpawn() { - when(event.getSpawnReason()).thenReturn(CreatureSpawnEvent.SpawnReason.SPAWNER); + void testFishInDeepColdOceanOverDarkPrismarineReplacedWithGuardian() { + prepareFishEvent(Biome.DEEP_COLD_OCEAN); + prepareWaterColumnTopped(Material.DARK_PRISMARINE); + when(settings.getGuardianChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world).spawnEntity(location, EntityType.GUARDIAN); + } + + @Test + void testFishInDeepFrozenOceanOverPrismarineBricksReplacedWithGuardian() { + prepareFishEvent(Biome.DEEP_FROZEN_OCEAN); + prepareWaterColumnTopped(Material.PRISMARINE_BRICKS); + when(settings.getGuardianChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world).spawnEntity(location, EntityType.GUARDIAN); + } + + @Test + void testFishInDeepLukewarmOceanOverPrismarineSlabReplacedWithGuardian() { + prepareFishEvent(Biome.DEEP_LUKEWARM_OCEAN); + prepareWaterColumnTopped(Material.PRISMARINE_SLAB); + when(settings.getGuardianChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world).spawnEntity(location, EntityType.GUARDIAN); + } + + @Test + void testFishInShallowOceanNotReplaced() { + prepareFishEvent(Biome.OCEAN); + prepareWaterColumnTopped(Material.PRISMARINE); + when(settings.getGuardianChance()).thenReturn(1.0); listener.onEntitySpawn(event); - verify(event, never()).isCancelled(); + verify(world, never()).spawnEntity(any(Location.class), eq(EntityType.GUARDIAN)); } + @Test + void testFishOverNonPrismarineNotReplaced() { + prepareFishEvent(Biome.DEEP_OCEAN); + prepareWaterColumnTopped(Material.SAND); + when(settings.getGuardianChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + } + + @Test + void testFishChanceZeroNoReplacement() { + prepareFishEvent(Biome.DEEP_OCEAN); + prepareWaterColumnTopped(Material.PRISMARINE); + + listener.onEntitySpawn(event); + + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + } + + @Test + void testNonFishInNormalWorldIgnored() { + LivingEntity zombie = mock(LivingEntity.class); + when(event.getEntity()).thenReturn(zombie); + when(event.getEntityType()).thenReturn(EntityType.ZOMBIE); + when(world.getEnvironment()).thenReturn(World.Environment.NORMAL); + when(settings.getGuardianChance()).thenReturn(1.0); + + listener.onEntitySpawn(event); + + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + } } diff --git a/src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java b/src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java deleted file mode 100644 index c8c12a5..0000000 --- a/src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java +++ /dev/null @@ -1,118 +0,0 @@ -package world.bentobox.extramobs.listeners.mocks; - -import static org.mockito.ArgumentMatchers.notNull; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.lang.reflect.Field; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.logging.Logger; - -import org.bukkit.Bukkit; -import org.bukkit.Keyed; -import org.bukkit.NamespacedKey; -import org.bukkit.Registry; -import org.bukkit.Server; -import org.bukkit.Tag; -import org.bukkit.UnsafeValues; -import org.eclipse.jdt.annotation.NonNull; - -public final class ServerMocks { - - public static @NonNull Server newServer() { - Server mock = mock(Server.class); - - Logger noOp = mock(Logger.class); - when(mock.getLogger()).thenReturn(noOp); - when(mock.isPrimaryThread()).thenReturn(true); - - // Unsafe - UnsafeValues unsafe = mock(UnsafeValues.class); - when(mock.getUnsafe()).thenReturn(unsafe); - - // Server must be available before tags can be mocked. - Bukkit.setServer(mock); - - // Bukkit has a lot of static constants referencing registry values. To initialize those, the - // registries must be able to be fetched before the classes are touched. - Map, Object> registers = new HashMap<>(); - - doAnswer(invocationGetRegistry -> registers.computeIfAbsent(invocationGetRegistry.getArgument(0), clazz -> { - Registry registry = mock(Registry.class); - Map cache = new HashMap<>(); - doAnswer(invocationGetEntry -> { - NamespacedKey key = invocationGetEntry.getArgument(0); - // Some classes (like BlockType and ItemType) have extra generics that will be - // erased during runtime calls. To ensure accurate typing, grab the constant's field. - // This approach also allows us to return null for unsupported keys. - Class constantClazz; - try { - //noinspection unchecked - constantClazz = (Class) clazz - .getField(key.getKey().toUpperCase(Locale.ROOT).replace('.', '_')).getType(); - } catch (ClassCastException e) { - throw new RuntimeException(e); - } catch (NoSuchFieldException e) { - return null; - } - - return cache.computeIfAbsent(key, key1 -> { - Keyed keyed = mock(constantClazz); - doReturn(key).when(keyed).getKey(); - return keyed; - }); - }).when(registry).get(notNull()); - return registry; - })).when(mock).getRegistry(notNull()); - - // Tags are dependent on registries, but use a different method. - // This will set up blank tags for each constant; all that needs to be done to render them - // functional is to re-mock Tag#getValues. - doAnswer(invocationGetTag -> { - Tag tag = mock(Tag.class); - doReturn(invocationGetTag.getArgument(1)).when(tag).getKey(); - doReturn(Set.of()).when(tag).getValues(); - doAnswer(invocationIsTagged -> { - Keyed keyed = invocationIsTagged.getArgument(0); - Class type = invocationGetTag.getArgument(2); - if (!type.isAssignableFrom(keyed.getClass())) { - return null; - } - // Since these are mocks, the exact instance might not be equal. Consider equal keys equal. - return tag.getValues().contains(keyed) - || tag.getValues().stream().anyMatch(value -> value.getKey().equals(keyed.getKey())); - }).when(tag).isTagged(notNull()); - return tag; - }).when(mock).getTag(notNull(), notNull(), notNull()); - - // Once the server is all set up, touch BlockType and ItemType to initialize. - // This prevents issues when trying to access dependent methods from a Material constant. - try { - Class.forName("org.bukkit.inventory.ItemType"); - Class.forName("org.bukkit.block.BlockType"); - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } - - return mock; - } - - public static void unsetBukkitServer() { - try { - Field server = Bukkit.class.getDeclaredField("server"); - server.setAccessible(true); - server.set(null, null); - } catch (NoSuchFieldException | IllegalArgumentException | IllegalAccessException e) { - throw new RuntimeException(e); - } - } - - private ServerMocks() { - } - -} \ No newline at end of file From a7db171baf792f2511f6d20e159f20f26391a72d Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 29 May 2026 18:30:05 -0700 Subject: [PATCH 12/19] Add GitHub Actions build with SonarCloud analysis Runs mvn verify on every push to develop/master and on PRs, then hands the result to SonarCloud under the bentobox-world organization and the BentoBoxWorld_ExtraMobs project key. SONAR_TOKEN is supplied by the repo secret. JDK 21 to match the new build target. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 38 +++++++++++++++++++++++++++++++++++++ pom.xml | 3 +++ 2 files changed, 41 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e38f7e1 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,38 @@ +name: Build +on: + push: + branches: + - develop + - master + pull_request: + types: [opened, synchronize, reopened] +jobs: + build: + name: Build and analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Set up JDK 21 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + with: + java-version: 21 + distribution: 'zulu' + - name: Cache SonarQube packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Cache Maven packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=BentoBoxWorld_ExtraMobs diff --git a/pom.xml b/pom.xml index cbb6444..623e3e4 100644 --- a/pom.xml +++ b/pom.xml @@ -57,6 +57,9 @@ --> 1.14.0 -LOCAL + + bentobox-world + https://sonarcloud.io From efe39b83440e1235fb20b15c124b72bb67e28961 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 18:41:30 -0700 Subject: [PATCH 13/19] feat: per-gamemode mob spawn replacement rules (#19) * Initial plan * feat: add per-gamemode mob spawn replacement config - New config/MobSpawnReplacement.java: POJO for a single mob replacement rule (old EntityType name, new EntityType name, chance). Provides resolveOldEntityType() / resolveNewEntityType() helpers. - Settings.java: add Map gamemodeSettings field at path "gamemode-settings" with full @ConfigComment documentation. Add getGamemodeSettings() / setGamemodeSettings() pair. Add getReplacements(gameModeName, environment) which parses the raw YAML map structure into typed MobSpawnReplacement lists; returns List.of() (never null) when no overrides are configured. - MobsSpawnListener.java: extract applyGameModeReplacements() helper that checks per-gamemode rules before each global-fallback branch (nether, end, world). When per-gamemode rules fire, global settings are skipped for that event. - config.yml: document new gamemode-settings section with commented multi-gamemode example (BSkyBlock, AcidIsland). - ExtraMobsAddonTest.java: include gamemode-settings: {} in config constant; add testGamemodeSettingsEmptyByDefault and testGetReplacementsEmptyWhenNoPerGamemodeConfig tests. - MobsSpawnListenerTest.java: default-stub getReplacements() to Collections.emptyList(); add 9 new tests covering per-gamemode nether/end/world paths, fallback on entity mismatch / chance=0, invalid mob name skipping, and Settings.getReplacements() unit tests. * refactor: address code review feedback - Settings.java: remove redundant field initializer on gamemodeSettings; setter null-guard and getReplacements() null-check are sufficient. - MobsSpawnListenerTest.java: add comment explaining why HashMap.put() is required instead of Map.of() for the raw gamemode-settings test. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../extramobs/config/MobSpawnReplacement.java | 193 ++++++++++++++++++ .../bentobox/extramobs/config/Settings.java | 128 ++++++++++++ .../listeners/MobsSpawnListener.java | 71 ++++++- src/main/resources/config.yml | 39 +++- .../extramobs/ExtraMobsAddonTest.java | 19 ++ .../listeners/MobsSpawnListenerTest.java | 143 +++++++++++++ 6 files changed, 589 insertions(+), 4 deletions(-) create mode 100644 src/main/java/world/bentobox/extramobs/config/MobSpawnReplacement.java diff --git a/src/main/java/world/bentobox/extramobs/config/MobSpawnReplacement.java b/src/main/java/world/bentobox/extramobs/config/MobSpawnReplacement.java new file mode 100644 index 0000000..d588375 --- /dev/null +++ b/src/main/java/world/bentobox/extramobs/config/MobSpawnReplacement.java @@ -0,0 +1,193 @@ +package world.bentobox.extramobs.config; + + +import org.bukkit.entity.EntityType; + + +/** + * Represents a single mob-spawn replacement rule used by the per-gamemode + * configuration. The rule matches a naturally-spawning entity ({@link #getOld()}) + * and, with a configured probability ({@link #getChance()}), replaces it with a + * different entity ({@link #getNew()}). + * + *

Mob names are stored as upper-case {@link EntityType} name strings so that + * the YAML serialisation layer (which only handles plain {@code Map} / {@code List} + * / scalar values for nested sections) does not need to know anything about Bukkit + * enum types. + */ +public class MobSpawnReplacement +{ + // --------------------------------------------------------------------- + // Section: Constructors + // --------------------------------------------------------------------- + + + /** + * No-arg constructor required by the YAML serialiser. + */ + public MobSpawnReplacement() + { + } + + + /** + * Convenience constructor. + * + * @param old EntityType name to match (case-insensitive). + * @param newMob EntityType name to spawn as a replacement (case-insensitive). + * @param chance Probability in the range [0.0, 1.0]. + */ + public MobSpawnReplacement(String old, String newMob, double chance) + { + this.old = old; + this.newMob = newMob; + this.chance = chance; + } + + + // --------------------------------------------------------------------- + // Section: Helpers + // --------------------------------------------------------------------- + + + /** + * Resolves the {@link EntityType} for the mob that this rule replaces. + * + * @return the resolved {@link EntityType}, or {@code null} if the name is + * invalid / unknown. + */ + public EntityType resolveOldEntityType() + { + if (old == null || old.isBlank()) + { + return null; + } + try + { + return EntityType.valueOf(old.toUpperCase()); + } + catch (IllegalArgumentException e) + { + return null; + } + } + + + /** + * Resolves the {@link EntityType} that should be spawned as the replacement. + * + * @return the resolved {@link EntityType}, or {@code null} if the name is + * invalid / unknown. + */ + public EntityType resolveNewEntityType() + { + if (newMob == null || newMob.isBlank()) + { + return null; + } + try + { + return EntityType.valueOf(newMob.toUpperCase()); + } + catch (IllegalArgumentException e) + { + return null; + } + } + + + // --------------------------------------------------------------------- + // Section: Getters and Setters + // --------------------------------------------------------------------- + + + /** + * Returns the name of the entity type that this rule matches. + * + * @return entity type name (upper-case). + */ + public String getOld() + { + return old; + } + + + /** + * Sets the name of the entity type that this rule matches. + * + * @param old entity type name (case-insensitive). + */ + public void setOld(String old) + { + this.old = old; + } + + + /** + * Returns the name of the entity type that will be spawned as a replacement. + * + * @return entity type name (upper-case). + */ + public String getNew() + { + return newMob; + } + + + /** + * Sets the name of the entity type to spawn as a replacement. + * + * @param newMob entity type name (case-insensitive). + */ + public void setNew(String newMob) + { + this.newMob = newMob; + } + + + /** + * Returns the spawn-replacement probability in the range [0.0, 1.0]. + * + * @return chance value. + */ + public double getChance() + { + return chance; + } + + + /** + * Sets the spawn-replacement probability. + * + * @param chance value in the range [0.0, 1.0]. + */ + public void setChance(double chance) + { + this.chance = chance; + } + + + // --------------------------------------------------------------------- + // Section: Variables + // --------------------------------------------------------------------- + + + /** + * Name of the entity type that this rule will replace. + * Field is named {@code old} to mirror the YAML key {@code old:}. + */ + private String old; + + /** + * Name of the replacement entity type. + * The field is named {@code newMob} because {@code new} is a Java keyword; + * the YAML key is {@code new:}. + */ + private String newMob; + + /** + * Probability that the replacement takes place (0.0 = never, 1.0 = always). + * The YAML key is {@code chance:}. + */ + private double chance; +} diff --git a/src/main/java/world/bentobox/extramobs/config/Settings.java b/src/main/java/world/bentobox/extramobs/config/Settings.java index efdaac1..530f63e 100644 --- a/src/main/java/world/bentobox/extramobs/config/Settings.java +++ b/src/main/java/world/bentobox/extramobs/config/Settings.java @@ -1,7 +1,11 @@ package world.bentobox.extramobs.config; +import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.Set; import world.bentobox.bentobox.api.configuration.ConfigComment; @@ -136,6 +140,105 @@ public void setGuardianChance(double guardianChance) } + /** + * Returns the raw per-gamemode settings map as loaded from {@code config.yml}. + * The map structure is: + *

+	 * gamemodeName -> {
+	 *   "nether" -> List<Map<String, Object>>,
+	 *   "end"    -> List<Map<String, Object>>,
+	 *   "world"  -> List<Map<String, Object>>
+	 * }
+	 * 
+ * Use {@link #getReplacements(String, String)} for convenient typed access. + * + * @return mutable map; never {@code null}. + */ + public Map getGamemodeSettings() + { + return gamemodeSettings; + } + + + /** + * Sets the raw per-gamemode settings map. + * + * @param gamemodeSettings new value (may be {@code null}; stored as empty map). + */ + public void setGamemodeSettings(Map gamemodeSettings) + { + this.gamemodeSettings = gamemodeSettings != null ? gamemodeSettings : new LinkedHashMap<>(); + } + + + /** + * Returns the list of {@link MobSpawnReplacement} rules configured for the + * given game mode and environment ({@code "world"}, {@code "nether"}, or + * {@code "end"}). + * + *

Returns an empty list when no per-gamemode overrides exist, allowing + * callers to fall back to global settings without extra null-checks. + * + * @param gameModeName name of the GameMode addon (e.g. {@code "BSkyBlock"}). + * @param environment one of {@code "world"}, {@code "nether"}, {@code "end"}. + * @return immutable-safe list of replacement rules; never {@code null}. + */ + public List getReplacements(String gameModeName, String environment) + { + if (gamemodeSettings == null || gameModeName == null || environment == null) + { + return List.of(); + } + + Object rawGM = gamemodeSettings.get(gameModeName); + + if (!(rawGM instanceof Map gmMap)) + { + return List.of(); + } + + Object rawEnv = gmMap.get(environment); + + if (!(rawEnv instanceof List envList)) + { + return List.of(); + } + + List result = new ArrayList<>(); + + for (Object rawEntry : envList) + { + if (!(rawEntry instanceof Map entryMap)) + { + continue; + } + + Object oldVal = entryMap.get("old"); + Object newVal = entryMap.get("new"); + Object chanceVal = entryMap.get("chance"); + + if (oldVal == null || newVal == null) + { + continue; + } + + double chance = 0.0; + + if (chanceVal instanceof Number n) + { + chance = n.doubleValue(); + } + + result.add(new MobSpawnReplacement( + oldVal.toString(), + newVal.toString(), + chance)); + } + + return result; + } + + // --------------------------------------------------------------------- // Section: Variables // --------------------------------------------------------------------- @@ -164,4 +267,29 @@ public void setGuardianChance(double guardianChance) @ConfigComment("Chance to spawn Guardian instead of a fish.") @ConfigEntry(path = "overworld-chance.guardian") private double guardianChance; + + @ConfigComment("") + @ConfigComment("Per-gamemode settings that override the global defaults above.") + @ConfigComment("Each key is the exact GameMode addon name (case-sensitive).") + @ConfigComment("Each gamemode may define up to three environment sections:") + @ConfigComment(" world: - replacements for the overworld") + @ConfigComment(" nether: - replacements for the nether") + @ConfigComment(" end: - replacements for the end") + @ConfigComment("Each section is a list of replacement rules with the following fields:") + @ConfigComment(" old: EntityType name of the mob to replace (e.g. ZOMBIFIED_PIGLIN)") + @ConfigComment(" new: EntityType name of the replacement mob (e.g. WITHER_SKELETON)") + @ConfigComment(" chance: Probability in the range 0.0-1.0") + @ConfigComment("Example:") + @ConfigComment(" gamemode-settings:") + @ConfigComment(" BSkyBlock:") + @ConfigComment(" nether:") + @ConfigComment(" - old: ZOMBIFIED_PIGLIN") + @ConfigComment(" new: WITHER_SKELETON") + @ConfigComment(" chance: 0.05") + @ConfigComment(" end:") + @ConfigComment(" - old: ENDERMAN") + @ConfigComment(" new: SHULKER") + @ConfigComment(" chance: 0.3") + @ConfigEntry(path = "gamemode-settings") + private Map gamemodeSettings; } diff --git a/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java b/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java index 04e22d5..688e6a2 100644 --- a/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java +++ b/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java @@ -69,16 +69,21 @@ public void onEntitySpawn(CreatureSpawnEvent event) return; } + String gameModeName = optionalAddon.get().getDescription().getName(); + if ((event.getEntityType().equals(EntityType.ZOMBIFIED_PIGLIN) || event.getEntityType().equals(EntityType.PIGLIN)) && this.addon.getPlugin().getIWM().isIslandNether(world)) { - // replace pigmen with blaze or wither - if (this.isSuitableNetherLocation(event.getLocation())) { + if (this.applyGameModeReplacements(event, gameModeName, "nether")) + { + return; + } + // Fall back to global settings if (this.spawningRandom.nextDouble() < this.addon.getSettings().getWitherSkeletonChance()) { // oOo wither skeleton got lucky. @@ -99,6 +104,12 @@ else if (event.getEntityType() == EntityType.ENDERMAN && // replace enderman with shulker if (this.isSuitableEndLocation(event.getLocation())) { + if (this.applyGameModeReplacements(event, gameModeName, "end")) + { + return; + } + + // Fall back to global settings if (this.spawningRandom.nextDouble() < this.addon.getSettings().getShulkerChance()) { // oOo shulker got lucky. @@ -109,7 +120,6 @@ else if (event.getEntityType() == EntityType.ENDERMAN && } else if (world.getEnvironment() == World.Environment.NORMAL && event.getEntity() instanceof Fish) { - // Check biome Biome biome = world.getBiome( event.getLocation().getBlockX(), @@ -125,6 +135,12 @@ else if (world.getEnvironment() == World.Environment.NORMAL && event.getEntity() if (this.isSuitableGuardianLocation(event.getLocation())) { + if (this.applyGameModeReplacements(event, gameModeName, "world")) + { + return; + } + + // Fall back to global settings if (this.spawningRandom.nextDouble() < this.addon.getSettings().getGuardianChance()) { // oOo guardian got lucky. @@ -198,6 +214,55 @@ private boolean isSuitableGuardianLocation(Location location) } + /** + * Attempts to apply per-gamemode replacement rules for the given environment. + * + *

Iterates through each configured {@link world.bentobox.extramobs.config.MobSpawnReplacement} + * rule for {@code gameModeName}/{@code environment}. For the first rule whose + * {@code old} mob matches the spawning entity type and whose random roll succeeds, + * the event is cancelled and the replacement entity is summoned. + * + * @param event the spawn event (will be cancelled on a successful match). + * @param gameModeName GameMode addon name resolved from the world. + * @param environment {@code "nether"}, {@code "end"}, or {@code "world"}. + * @return {@code true} if a per-gamemode rule was applied (callers should skip + * further processing); {@code false} if no matching rule was found. + */ + private boolean applyGameModeReplacements( + CreatureSpawnEvent event, + String gameModeName, + String environment) + { + var rules = this.addon.getSettings().getReplacements(gameModeName, environment); + + if (rules.isEmpty()) + { + return false; + } + + for (var rule : rules) + { + org.bukkit.entity.EntityType oldType = rule.resolveOldEntityType(); + org.bukkit.entity.EntityType newType = rule.resolveNewEntityType(); + + if (oldType == null || newType == null) + { + continue; + } + + if (event.getEntityType() == oldType + && this.spawningRandom.nextDouble() < rule.getChance()) + { + this.summonEntity(event.getLocation(), newType); + event.setCancelled(true); + return true; + } + } + + return false; + } + + /** * This method spawns entity in given location. * @param location Location where entity must be summoned. diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4fc8c28..58fb817 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -15,4 +15,41 @@ end-chances: shulker: 0.1 overworld-chance: # Chance to spawn Guardian instead of a fish. - guardian: 0.1 \ No newline at end of file + guardian: 0.1 +# +# Per-gamemode settings that override the global defaults above. +# Each key is the exact GameMode addon name (case-sensitive). +# Each gamemode may define up to three environment sections: +# world: - replacements for the overworld +# nether: - replacements for the nether +# end: - replacements for the end +# Each section is a list of replacement rules: +# old: EntityType name of the mob to replace (e.g. ZOMBIFIED_PIGLIN) +# new: EntityType name of the replacement mob (e.g. WITHER_SKELETON) +# chance: Probability in the range 0.0-1.0 +# When per-gamemode rules are present for an environment, the global defaults +# for that environment are NOT applied to that gamemode. +# Example: +# gamemode-settings: +# BSkyBlock: +# nether: +# - old: ZOMBIFIED_PIGLIN +# new: WITHER_SKELETON +# chance: 0.05 +# - old: ZOMBIFIED_PIGLIN +# new: BLAZE +# chance: 0.1 +# end: +# - old: ENDERMAN +# new: SHULKER +# chance: 0.3 +# world: +# - old: COD +# new: GUARDIAN +# chance: 0.15 +# AcidIsland: +# end: +# - old: ENDERMAN +# new: SHULKER +# chance: 0.5 +gamemode-settings: {} \ No newline at end of file diff --git a/src/test/java/world/bentobox/extramobs/ExtraMobsAddonTest.java b/src/test/java/world/bentobox/extramobs/ExtraMobsAddonTest.java index e9edb2d..ee848af 100644 --- a/src/test/java/world/bentobox/extramobs/ExtraMobsAddonTest.java +++ b/src/test/java/world/bentobox/extramobs/ExtraMobsAddonTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +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; @@ -44,6 +45,7 @@ class ExtraMobsAddonTest extends CommonTestSetup { shulker: 0.1 overworld-chance: guardian: 0.1 + gamemode-settings: {} """; @Mock @@ -165,4 +167,21 @@ void testOnReloadPreservesSettings() { addon.onReload(); assertEquals(0.01, addon.getSettings().getWitherSkeletonChance(), 1e-9); } + + @Test + void testGamemodeSettingsEmptyByDefault() { + addon.onLoad(); + Settings s = addon.getSettings(); + assertNotNull(s.getGamemodeSettings()); + assertEquals(0, s.getGamemodeSettings().size()); + } + + @Test + void testGetReplacementsEmptyWhenNoPerGamemodeConfig() { + addon.onLoad(); + Settings s = addon.getSettings(); + assertTrue(s.getReplacements("BSkyBlock", "nether").isEmpty()); + assertTrue(s.getReplacements("BSkyBlock", "end").isEmpty()); + assertTrue(s.getReplacements("BSkyBlock", "world").isEmpty()); + } } diff --git a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java index 587e70c..0471111 100644 --- a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java +++ b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java @@ -8,6 +8,8 @@ import static org.mockito.Mockito.when; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -28,6 +30,7 @@ import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.extramobs.CommonTestSetup; import world.bentobox.extramobs.ExtraMobsAddon; +import world.bentobox.extramobs.config.MobSpawnReplacement; import world.bentobox.extramobs.config.Settings; /** @@ -62,6 +65,8 @@ public void setUp() throws Exception { when(settings.getBlazeChance()).thenReturn(0.0); when(settings.getShulkerChance()).thenReturn(0.0); when(settings.getGuardianChance()).thenReturn(0.0); + // Default: no per-gamemode rules configured + when(settings.getReplacements(any(), any())).thenReturn(Collections.emptyList()); // GameMode resolved by default AddonDescription desc = new AddonDescription.Builder("main.Class", "BSkyBlock", "1.0").build(); @@ -395,4 +400,142 @@ void testNonFishInNormalWorldIgnored() { verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); } + + // ── Per-gamemode settings ────────────────────────────────────────────── + + /** + * Helper: stubs settings.getReplacements(gameModeName, env) to return rules + * and ensures global chance methods return 0 so they cannot fire. + */ + private void stubGameModeReplacement(String env, String oldMob, String newMob, double chance) { + MobSpawnReplacement rule = new MobSpawnReplacement(oldMob, newMob, chance); + when(settings.getReplacements("BSkyBlock", env)).thenReturn(List.of(rule)); + // Ensure all global fallbacks are 0 so they cannot trigger + when(settings.getWitherSkeletonChance()).thenReturn(0.0); + when(settings.getBlazeChance()).thenReturn(0.0); + when(settings.getShulkerChance()).thenReturn(0.0); + when(settings.getGuardianChance()).thenReturn(0.0); + } + + @Test + void testPerGameModeNetherReplacement() { + stubGameModeReplacement("nether", "ZOMBIFIED_PIGLIN", "BLAZE", 1.0); + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); + when(iwm.isIslandNether(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.NETHER_BRICKS); + + listener.onEntitySpawn(event); + + verify(world).spawnEntity(location, EntityType.BLAZE); + verify(event).setCancelled(true); + } + + @Test + void testPerGameModeNetherReplacementEntityMismatchFallsBackToGlobal() { + // Rule targets ENDERMAN (wrong entity for the nether branch), chance 1.0 + stubGameModeReplacement("nether", "ENDERMAN", "SHULKER", 1.0); + // Enable global wither-skeleton so it fires as fallback + when(settings.getWitherSkeletonChance()).thenReturn(1.0); + + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); + when(iwm.isIslandNether(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.NETHER_BRICKS); + + listener.onEntitySpawn(event); + + // Per-gamemode rule does not match → global wither-skeleton fires + verify(world).spawnEntity(location, EntityType.WITHER_SKELETON); + verify(event).setCancelled(true); + } + + @Test + void testPerGameModeNetherChanceZeroFallsBackToGlobal() { + stubGameModeReplacement("nether", "ZOMBIFIED_PIGLIN", "BLAZE", 0.0); + when(settings.getWitherSkeletonChance()).thenReturn(1.0); + + when(event.getEntityType()).thenReturn(EntityType.ZOMBIFIED_PIGLIN); + when(iwm.isIslandNether(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.NETHER_BRICKS); + + listener.onEntitySpawn(event); + + // Per-gamemode chance is 0 → rule not applied → global fires + verify(world).spawnEntity(location, EntityType.WITHER_SKELETON); + } + + @Test + void testPerGameModeEndReplacement() { + stubGameModeReplacement("end", "ENDERMAN", "SHULKER", 1.0); + when(event.getEntityType()).thenReturn(EntityType.ENDERMAN); + when(iwm.isIslandEnd(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.PURPUR_BLOCK); + + listener.onEntitySpawn(event); + + verify(world).spawnEntity(location, EntityType.SHULKER); + verify(event).setCancelled(true); + } + + @Test + void testPerGameModeEndInvalidMobNameSkipped() { + // Both old and new names are invalid entity types + MobSpawnReplacement bad = new MobSpawnReplacement("INVALID_MOB", "ALSO_INVALID", 1.0); + when(settings.getReplacements("BSkyBlock", "end")).thenReturn(List.of(bad)); + when(settings.getShulkerChance()).thenReturn(1.0); + + when(event.getEntityType()).thenReturn(EntityType.ENDERMAN); + when(iwm.isIslandEnd(world)).thenReturn(true); + when(blockBelow.getType()).thenReturn(Material.PURPUR_BLOCK); + + listener.onEntitySpawn(event); + + // Invalid rule is skipped → global shulker fires + verify(world).spawnEntity(location, EntityType.SHULKER); + } + + @Test + void testPerGameModeWorldReplacement() { + stubGameModeReplacement("world", "COD", "GUARDIAN", 1.0); + prepareFishEvent(Biome.DEEP_OCEAN); + prepareWaterColumnTopped(Material.PRISMARINE); + + listener.onEntitySpawn(event); + + verify(world).spawnEntity(location, EntityType.GUARDIAN); + verify(event).setCancelled(true); + } + + @Test + void testGetReplacementsNullInputs() { + Settings s = new Settings(); + // All null/empty paths should return empty list without NPE + assert s.getReplacements(null, "nether").isEmpty(); + assert s.getReplacements("BSkyBlock", null).isEmpty(); + assert s.getReplacements("BSkyBlock", "nether").isEmpty(); + } + + @Test + void testGetReplacementsParsesRawMap() { + Settings s = new Settings(); + // Build the same structure that BentoBox/snakeyaml would produce when + // loading the YAML from config. + Map rule = Map.of( + "old", "ZOMBIFIED_PIGLIN", + "new", "WITHER_SKELETON", + "chance", 0.05); + Map gmSection = Map.of( + "nether", List.of(rule)); + Map raw = new java.util.HashMap<>(); + // Map.of("BSkyBlock", gmSection) would infer Map>, + // which is not assignable to Map; use HashMap.put() instead. + raw.put("BSkyBlock", gmSection); + s.setGamemodeSettings(raw); + + List result = s.getReplacements("BSkyBlock", "nether"); + assert result.size() == 1; + MobSpawnReplacement r = result.get(0); + assert r.resolveOldEntityType() == EntityType.ZOMBIFIED_PIGLIN; + assert r.resolveNewEntityType() == EntityType.WITHER_SKELETON; + assert Math.abs(r.getChance() - 0.05) < 1e-9; + } } From c9428ea49e69d80c6df0caf336f12f577b2c4ccf Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 29 May 2026 18:42:36 -0700 Subject: [PATCH 14/19] Update build version to 1.15.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 623e3e4..c6c3a26 100644 --- a/pom.xml +++ b/pom.xml @@ -55,7 +55,7 @@ X.Y.Z -> BentoBox core version .M -> Addon development iteration. --> - 1.14.0 + 1.15.0 -LOCAL bentobox-world From dd178df7c26f28d2d6ac48d3162e01be5ea8fff7 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 29 May 2026 18:45:54 -0700 Subject: [PATCH 15/19] Refresh README to match modern addon style Drops the Alpha-stage notice, restructures the page with the same section layout used by the recently-updated DimensionalTrees README, and rewrites the configuration and replacement-rules sections to reflect the current Nether / End / Overworld behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 128 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 93 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index cd5bee4..4840b41 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,118 @@ -# ExtraMobs Addon +# 👾 ExtraMobs Add-on for BentoBox [![Discord](https://img.shields.io/discord/272499714048524288.svg?logo=discord)](https://discord.bentobox.world) [![Build Status](https://ci.codemc.org/buildStatus/icon?job=BentoBoxWorld/ExtraMobs)](https://ci.codemc.org/job/BentoBoxWorld/job/ExtraMobs/) -Add-on for BentoBox that adjusts some mob spawning rules to get Blazes, Wither Skeleton, and Shulkers. +## 🔍 What is ExtraMobs? -## Where to find +**ExtraMobs** is a BentoBox add-on that lets players spawn **Blazes**, **Wither Skeletons**, **Shulkers** and **Guardians** on their islands by building the right structures. It does **not** change Minecraft's spawning rules — it watches for natural spawns and replaces certain mobs by chance when the surrounding blocks match a themed pattern. -Currently ExtraMobs Addon is in **Alpha stage**, so it may or may not contain bugs... a lot of bugs. Also it means, that some features are not working or implemented. -You can download it from [Release tab](https://github.com/BentoBoxWorld/ExtraMobs/releases) +Works with every BentoBox game mode (AcidIsland, BSkyBlock, CaveBlock, SkyGrid, …). -Or you can try **nightly builds** where you can check and test new features that will be implemented in next release from [Jenkins Server](https://ci.codemc.org/job/BentoBoxWorld/job/ExtraMobs/lastStableBuild/). +--- -If you like this addon but something is missing or is not working as you want, you can always submit an [Issue request](https://github.com/BentoBoxWorld/ExtraMobs/issues) or get a support in Discord [BentoBox ![icon](https://avatars2.githubusercontent.com/u/41555324?s=15&v=4)](https://discord.bentobox.world) +## 🚀 Getting Started -## How to use +1. Place the **ExtraMobs** `.jar` into your BentoBox `addons` folder. +2. Restart your server. +3. The addon creates `addons/ExtraMobs/config.yml`. +4. Edit `config.yml` to adjust the spawn chances or disable specific game modes. +5. Restart the server (or reload BentoBox) to apply your changes. -1. Place the addon jar in the addons folder of the BentoBox plugin -2. Restart the server -3. In game you can change flags that allows to use current addon. +--- -## Information +## ✨ How Replacements Work -This addon does not change Minecraft spawning rules. Instead it uses other mobs that are naturally generated and change their type with new entity, if all conditions are met. +ExtraMobs only acts on **natural** spawns inside a BentoBox-managed world. When a candidate mob spawns on a themed block, ExtraMobs rolls against the configured chance; on success the original spawn is cancelled and the replacement entity is summoned in its place. -##### For Wither Skeleton and Blaze: +### 🔥 Nether — Wither Skeleton & Blaze -Addon will replace Zombie Pigmen with Blaze or Wither Skeleton by chance from config, if: - - given world is generated by GameMode Addon. - - given world is Nether - - Zombie Pigmen is standing on nether brick, nether brick slab or nether brick stairs. +A **Zombified Piglin** or **Piglin** is replaced when: +- the world is the BentoBox Nether, +- and the mob is standing on **nether brick**, **nether brick slab**, or **nether brick stairs**. -##### For Shulkers: +The wither skeleton roll is checked first, then the blaze roll. -Addon will replace Enderman with Shulker by chance from config if: - - given world is generated by GameMode Addon. - - given world is the End - - Enderman is standing on purpur block, purpur stair or purpur slab. +### 🌌 End — Shulker -##### For Guardians: +An **Enderman** is replaced with a **Shulker** when: +- the world is the BentoBox End, +- and the mob is standing on a **purpur block**, **purpur slab**, or **purpur stairs**. -Addon will replace Cod, Salmon or Tropical fish with Guardian by chance from config if: - - given world is generated by GameMode Addon. - - given world is the Overworld - - biome in given location is deep ocean or any its variants - - first block above water where fish is spawned is prismarine, prismarine brick or dark prismarine (blocks, slabs and stairs). +### 🌊 Overworld — Guardian +A naturally-spawned **Cod**, **Salmon**, or **Tropical Fish** is replaced with a **Guardian** when: +- the world is the BentoBox Overworld, +- the biome is **Deep Ocean** (or Deep Cold / Deep Frozen / Deep Lukewarm), +- and the first non-water block above the fish is **prismarine**, **prismarine bricks**, or **dark prismarine** (block, slab, or stairs variant). -## Compatibility +--- -- [x] BentoBox - 1.11.0 version +## ⚙️ Configuration -Addon is build on Minecraft 1.15.2 and BentoBox 1.11.0 version, however, it should even work on Minecraft 1.13.2 and BentoBox 1.0 Release. +### Global Defaults -Addon supports all Game mode addons. +```yaml +# Game modes in which ExtraMobs should not run. +# Add the GameMode addon name (e.g. BSkyBlock, AcidIsland, CaveBlock). +disabled-gamemodes: [] +nether-chances: + # Chance (0.0–1.0) to spawn a Wither Skeleton instead of a Zombified Piglin. + wither-skeleton: 0.01 + # Chance (0.0–1.0) to spawn a Blaze instead of a Zombified Piglin. + blaze: 0.1 -## Information +end-chances: + # Chance (0.0–1.0) to spawn a Shulker instead of an Enderman. + shulker: 0.1 -More information can be found in [Wiki Pages](https://github.com/BentoBoxWorld/ExtraMobs/wiki). +overworld-chance: + # Chance (0.0–1.0) to spawn a Guardian instead of a fish. + guardian: 0.1 +``` + +All chance values are decimals between `0.0` (never) and `1.0` (always). For the Nether the wither skeleton roll is evaluated before the blaze roll, so the blaze chance is effectively conditional on the wither skeleton roll failing. + +### Per-Gamemode Overrides + +You can define replacement rules that only apply to a specific game mode. Each gamemode may set rules for the `nether`, `end`, and/or `world` (overworld) environments. **When per-gamemode rules are present for an environment, the global defaults above are not applied for that gamemode.** + +```yaml +gamemode-settings: + BSkyBlock: + nether: + - old: ZOMBIFIED_PIGLIN + new: WITHER_SKELETON + chance: 0.05 + - old: ZOMBIFIED_PIGLIN + new: BLAZE + chance: 0.1 + end: + - old: ENDERMAN + new: SHULKER + chance: 0.3 + world: + - old: COD + new: GUARDIAN + chance: 0.15 + AcidIsland: + end: + - old: ENDERMAN + new: SHULKER + chance: 0.5 +``` + +Each rule needs: +- `old` — the EntityType name of the mob to replace (e.g. `ZOMBIFIED_PIGLIN`, `ENDERMAN`, `COD`). +- `new` — the EntityType name of the replacement (e.g. `WITHER_SKELETON`, `SHULKER`, `GUARDIAN`). +- `chance` — probability in the range `0.0`–`1.0`. + +The gamemode key must exactly match the GameMode addon name as registered in BentoBox (`BSkyBlock`, `AcidIsland`, `CaveBlock`, `SkyGrid`, …). Themed-block requirements (nether brick / purpur / prismarine) still apply to per-gamemode rules. + +--- + +## 🐛 Bugs and Feature Requests + +Please submit issues at [GitHub Issues](https://github.com/BentoBoxWorld/ExtraMobs/issues) or ask in the [BentoBox Discord](https://discord.bentobox.world). + +More information is available on the [Wiki](https://github.com/BentoBoxWorld/ExtraMobs/wiki). From 6955e9e8a36d0eeb810f1574b0efe73dcb6e65df Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 12:18:58 -0700 Subject: [PATCH 16/19] Remove obsolete ServerMocks after master merge The merge from master reintroduced ServerMocks.java and added a stale import to MobsSpawnListenerTest. ServerMocks predates the MockBukkit-based test infrastructure and no longer compiles against Paper 1.21.11 (its Registry.get call is ambiguous against the new Registry> overload). MockBukkit covers the same ground via CommonTestSetup, so drop the file and its now-unused import. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../listeners/MobsSpawnListenerTest.java | 1 - .../listeners/mocks/ServerMocks.java | 118 ------------------ 2 files changed, 119 deletions(-) delete mode 100644 src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java diff --git a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java index 7f2cb7c..0471111 100644 --- a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java +++ b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java @@ -32,7 +32,6 @@ import world.bentobox.extramobs.ExtraMobsAddon; import world.bentobox.extramobs.config.MobSpawnReplacement; import world.bentobox.extramobs.config.Settings; -import world.bentobox.extramobs.listeners.mocks.ServerMocks; /** * Tests for {@link MobsSpawnListener}. diff --git a/src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java b/src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java deleted file mode 100644 index c8c12a5..0000000 --- a/src/test/java/world/bentobox/extramobs/listeners/mocks/ServerMocks.java +++ /dev/null @@ -1,118 +0,0 @@ -package world.bentobox.extramobs.listeners.mocks; - -import static org.mockito.ArgumentMatchers.notNull; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.lang.reflect.Field; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.logging.Logger; - -import org.bukkit.Bukkit; -import org.bukkit.Keyed; -import org.bukkit.NamespacedKey; -import org.bukkit.Registry; -import org.bukkit.Server; -import org.bukkit.Tag; -import org.bukkit.UnsafeValues; -import org.eclipse.jdt.annotation.NonNull; - -public final class ServerMocks { - - public static @NonNull Server newServer() { - Server mock = mock(Server.class); - - Logger noOp = mock(Logger.class); - when(mock.getLogger()).thenReturn(noOp); - when(mock.isPrimaryThread()).thenReturn(true); - - // Unsafe - UnsafeValues unsafe = mock(UnsafeValues.class); - when(mock.getUnsafe()).thenReturn(unsafe); - - // Server must be available before tags can be mocked. - Bukkit.setServer(mock); - - // Bukkit has a lot of static constants referencing registry values. To initialize those, the - // registries must be able to be fetched before the classes are touched. - Map, Object> registers = new HashMap<>(); - - doAnswer(invocationGetRegistry -> registers.computeIfAbsent(invocationGetRegistry.getArgument(0), clazz -> { - Registry registry = mock(Registry.class); - Map cache = new HashMap<>(); - doAnswer(invocationGetEntry -> { - NamespacedKey key = invocationGetEntry.getArgument(0); - // Some classes (like BlockType and ItemType) have extra generics that will be - // erased during runtime calls. To ensure accurate typing, grab the constant's field. - // This approach also allows us to return null for unsupported keys. - Class constantClazz; - try { - //noinspection unchecked - constantClazz = (Class) clazz - .getField(key.getKey().toUpperCase(Locale.ROOT).replace('.', '_')).getType(); - } catch (ClassCastException e) { - throw new RuntimeException(e); - } catch (NoSuchFieldException e) { - return null; - } - - return cache.computeIfAbsent(key, key1 -> { - Keyed keyed = mock(constantClazz); - doReturn(key).when(keyed).getKey(); - return keyed; - }); - }).when(registry).get(notNull()); - return registry; - })).when(mock).getRegistry(notNull()); - - // Tags are dependent on registries, but use a different method. - // This will set up blank tags for each constant; all that needs to be done to render them - // functional is to re-mock Tag#getValues. - doAnswer(invocationGetTag -> { - Tag tag = mock(Tag.class); - doReturn(invocationGetTag.getArgument(1)).when(tag).getKey(); - doReturn(Set.of()).when(tag).getValues(); - doAnswer(invocationIsTagged -> { - Keyed keyed = invocationIsTagged.getArgument(0); - Class type = invocationGetTag.getArgument(2); - if (!type.isAssignableFrom(keyed.getClass())) { - return null; - } - // Since these are mocks, the exact instance might not be equal. Consider equal keys equal. - return tag.getValues().contains(keyed) - || tag.getValues().stream().anyMatch(value -> value.getKey().equals(keyed.getKey())); - }).when(tag).isTagged(notNull()); - return tag; - }).when(mock).getTag(notNull(), notNull(), notNull()); - - // Once the server is all set up, touch BlockType and ItemType to initialize. - // This prevents issues when trying to access dependent methods from a Material constant. - try { - Class.forName("org.bukkit.inventory.ItemType"); - Class.forName("org.bukkit.block.BlockType"); - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } - - return mock; - } - - public static void unsetBukkitServer() { - try { - Field server = Bukkit.class.getDeclaredField("server"); - server.setAccessible(true); - server.set(null, null); - } catch (NoSuchFieldException | IllegalArgumentException | IllegalAccessException e) { - throw new RuntimeException(e); - } - } - - private ServerMocks() { - } - -} \ No newline at end of file From 3e04c211dcef55af34075c04734c487258439f94 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 17:47:54 -0700 Subject: [PATCH 17/19] Address SonarCloud maintainability issues - MobsSpawnListener.onEntitySpawn: split the three world-type branches into handleNetherSpawn / handleEndSpawn / handleOverworldSpawn, and extract resolveActiveGameMode for the GameMode lookup. Drops cognitive complexity from 42 to under the 15-rule limit (S3776). - MobsSpawnListener.isSuitableGuardianLocation: drop the always-true block != null guard inside the WATER walk (S2589). - MobsSpawnListener: in the overworld branch, combine the deep-ocean biome check with the suitable-block check into one guard so there's no nested if (S1066). Move the four block lookup lists to static Set constants while we're touching them, since the per-call array allocation showed up in the same hotspot. - Settings.getReplacements: extract parseReplacement helper so the loop has zero continue statements (S135). - MobsSpawnListenerTest: replace the Java `assert` keyword in testGetReplacementsNullInputs / testGetReplacementsParsesRawMap with JUnit assertEquals / assertTrue so the tests actually verify state when -ea is not set (S2699). 42/42 tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../bentobox/extramobs/config/Settings.java | 57 ++-- .../listeners/MobsSpawnListener.java | 253 ++++++++++-------- .../listeners/MobsSpawnListenerTest.java | 16 +- 3 files changed, 185 insertions(+), 141 deletions(-) diff --git a/src/main/java/world/bentobox/extramobs/config/Settings.java b/src/main/java/world/bentobox/extramobs/config/Settings.java index 530f63e..51a212e 100644 --- a/src/main/java/world/bentobox/extramobs/config/Settings.java +++ b/src/main/java/world/bentobox/extramobs/config/Settings.java @@ -6,6 +6,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import world.bentobox.bentobox.api.configuration.ConfigComment; @@ -208,37 +209,43 @@ public List getReplacements(String gameModeName, String env for (Object rawEntry : envList) { - if (!(rawEntry instanceof Map entryMap)) - { - continue; - } - - Object oldVal = entryMap.get("old"); - Object newVal = entryMap.get("new"); - Object chanceVal = entryMap.get("chance"); - - if (oldVal == null || newVal == null) - { - continue; - } - - double chance = 0.0; - - if (chanceVal instanceof Number n) - { - chance = n.doubleValue(); - } - - result.add(new MobSpawnReplacement( - oldVal.toString(), - newVal.toString(), - chance)); + parseReplacement(rawEntry).ifPresent(result::add); } return result; } + /** + * Parses a single raw YAML map entry into a {@link MobSpawnReplacement} when the + * required {@code old} and {@code new} keys are present. Malformed entries (not + * a map, missing keys) yield an empty {@link Optional} so the caller can drop + * them silently. + */ + private static Optional parseReplacement(Object rawEntry) + { + if (!(rawEntry instanceof Map entryMap)) + { + return Optional.empty(); + } + + Object oldVal = entryMap.get("old"); + Object newVal = entryMap.get("new"); + + if (oldVal == null || newVal == null) + { + return Optional.empty(); + } + + double chance = entryMap.get("chance") instanceof Number n ? n.doubleValue() : 0.0; + + return Optional.of(new MobSpawnReplacement( + oldVal.toString(), + newVal.toString(), + chance)); + } + + // --------------------------------------------------------------------- // Section: Variables // --------------------------------------------------------------------- diff --git a/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java b/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java index 688e6a2..b8d0f79 100644 --- a/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java +++ b/src/main/java/world/bentobox/extramobs/listeners/MobsSpawnListener.java @@ -3,6 +3,7 @@ import java.util.Optional; import java.util.Random; +import java.util.Set; import org.bukkit.Location; import org.bukkit.Material; @@ -28,6 +29,34 @@ */ public class MobsSpawnListener implements Listener { + private static final Set DEEP_OCEAN_BIOMES = Set.of( + Biome.DEEP_OCEAN, + Biome.DEEP_COLD_OCEAN, + Biome.DEEP_FROZEN_OCEAN, + Biome.DEEP_LUKEWARM_OCEAN); + + private static final Set NETHER_BRICKS = Set.of( + Material.NETHER_BRICKS, + Material.NETHER_BRICK_SLAB, + Material.NETHER_BRICK_STAIRS); + + private static final Set PURPUR_BLOCKS = Set.of( + Material.PURPUR_BLOCK, + Material.PURPUR_SLAB, + Material.PURPUR_STAIRS); + + private static final Set PRISMARINE_BLOCKS = Set.of( + Material.PRISMARINE, + Material.PRISMARINE_SLAB, + Material.PRISMARINE_STAIRS, + Material.PRISMARINE_BRICKS, + Material.PRISMARINE_BRICK_SLAB, + Material.PRISMARINE_BRICK_STAIRS, + Material.DARK_PRISMARINE, + Material.DARK_PRISMARINE_SLAB, + Material.DARK_PRISMARINE_STAIRS); + + /** * Constructor MobsSpawnListener creates a new MobsSpawnListener instance. * @@ -50,105 +79,131 @@ public void onEntitySpawn(CreatureSpawnEvent event) { if (event.getSpawnReason() != CreatureSpawnEvent.SpawnReason.NATURAL) { - // Effect only natural mob spawning. return; } World world = event.getLocation().getWorld(); + String gameModeName = this.resolveActiveGameMode(world); + + if (gameModeName == null) + { + return; + } + + EntityType entityType = event.getEntityType(); + + if (this.isPiglin(entityType) && this.addon.getPlugin().getIWM().isIslandNether(world)) + { + this.handleNetherSpawn(event, gameModeName); + } + else if (entityType == EntityType.ENDERMAN && this.addon.getPlugin().getIWM().isIslandEnd(world)) + { + this.handleEndSpawn(event, gameModeName); + } + else if (world.getEnvironment() == World.Environment.NORMAL && event.getEntity() instanceof Fish) + { + this.handleOverworldSpawn(event, world, gameModeName); + } + } + + + /** + * Resolves the GameMode addon that owns the given world, or {@code null} if no + * GameMode applies or the GameMode is in the {@code disabled-gamemodes} list. + */ + private String resolveActiveGameMode(World world) + { + Optional optionalAddon = this.addon.getPlugin().getIWM().getAddon(world); + + if (optionalAddon.isEmpty()) + { + return null; + } + + String name = optionalAddon.get().getDescription().getName(); + + if (this.addon.getSettings().getDisabledGameModes().contains(name)) + { + return null; + } + + return name; + } + + + private boolean isPiglin(EntityType type) + { + return type == EntityType.ZOMBIFIED_PIGLIN || type == EntityType.PIGLIN; + } + + + private void handleNetherSpawn(CreatureSpawnEvent event, String gameModeName) + { + if (!this.isSuitableNetherLocation(event.getLocation())) + { + return; + } + + if (this.applyGameModeReplacements(event, gameModeName, "nether")) + { + return; + } + + if (this.spawningRandom.nextDouble() < this.addon.getSettings().getWitherSkeletonChance()) + { + this.summonEntity(event.getLocation(), EntityType.WITHER_SKELETON); + event.setCancelled(true); + } + else if (this.spawningRandom.nextDouble() < this.addon.getSettings().getBlazeChance()) + { + this.summonEntity(event.getLocation(), EntityType.BLAZE); + event.setCancelled(true); + } + } - Optional optionalAddon = - this.addon.getPlugin().getIWM().getAddon(world); - if (!optionalAddon.isPresent() || - (!this.addon.getSettings().getDisabledGameModes().isEmpty() - && - this.addon.getSettings().getDisabledGameModes().contains( - optionalAddon.get().getDescription().getName()))) + private void handleEndSpawn(CreatureSpawnEvent event, String gameModeName) + { + if (!this.isSuitableEndLocation(event.getLocation())) { - // GameMode addon is not in enable list. return; } - String gameModeName = optionalAddon.get().getDescription().getName(); + if (this.applyGameModeReplacements(event, gameModeName, "end")) + { + return; + } - if ((event.getEntityType().equals(EntityType.ZOMBIFIED_PIGLIN) - || event.getEntityType().equals(EntityType.PIGLIN)) - && this.addon.getPlugin().getIWM().isIslandNether(world)) + if (this.spawningRandom.nextDouble() < this.addon.getSettings().getShulkerChance()) { - // replace pigmen with blaze or wither - if (this.isSuitableNetherLocation(event.getLocation())) - { - if (this.applyGameModeReplacements(event, gameModeName, "nether")) - { - return; - } - - // Fall back to global settings - if (this.spawningRandom.nextDouble() < this.addon.getSettings().getWitherSkeletonChance()) - { - // oOo wither skeleton got lucky. - this.summonEntity(event.getLocation(), EntityType.WITHER_SKELETON); - event.setCancelled(true); - } - else if (this.spawningRandom.nextDouble() < this.addon.getSettings().getBlazeChance()) - { - // oOo blaze got lucky. - this.summonEntity(event.getLocation(), EntityType.BLAZE); - event.setCancelled(true); - } - } + this.summonEntity(event.getLocation(), EntityType.SHULKER); + event.setCancelled(true); } - else if (event.getEntityType() == EntityType.ENDERMAN && - this.addon.getPlugin().getIWM().isIslandEnd(world)) + } + + + private void handleOverworldSpawn(CreatureSpawnEvent event, World world, String gameModeName) + { + Biome biome = world.getBiome( + event.getLocation().getBlockX(), + event.getLocation().getBlockY(), + event.getLocation().getBlockZ()); + + // Monuments are located only in Deep Ocean. So guardians will spawn there. + if (!DEEP_OCEAN_BIOMES.contains(biome) || !this.isSuitableGuardianLocation(event.getLocation())) { - // replace enderman with shulker - if (this.isSuitableEndLocation(event.getLocation())) - { - if (this.applyGameModeReplacements(event, gameModeName, "end")) - { - return; - } - - // Fall back to global settings - if (this.spawningRandom.nextDouble() < this.addon.getSettings().getShulkerChance()) - { - // oOo shulker got lucky. - this.summonEntity(event.getLocation(), EntityType.SHULKER); - event.setCancelled(true); - } - } + return; } - else if (world.getEnvironment() == World.Environment.NORMAL && event.getEntity() instanceof Fish) + + if (this.applyGameModeReplacements(event, gameModeName, "world")) { - // Check biome - Biome biome = world.getBiome( - event.getLocation().getBlockX(), - event.getLocation().getBlockY(), - event.getLocation().getBlockZ()); - - if (biome == Biome.DEEP_OCEAN || - biome == Biome.DEEP_COLD_OCEAN || - biome == Biome.DEEP_FROZEN_OCEAN || - biome == Biome.DEEP_LUKEWARM_OCEAN) - { - // Monuments are located only in Deep Ocean. So guardians will spawn there. - - if (this.isSuitableGuardianLocation(event.getLocation())) - { - if (this.applyGameModeReplacements(event, gameModeName, "world")) - { - return; - } - - // Fall back to global settings - if (this.spawningRandom.nextDouble() < this.addon.getSettings().getGuardianChance()) - { - // oOo guardian got lucky. - this.summonEntity(event.getLocation(), EntityType.GUARDIAN); - event.setCancelled(true); - } - } - } + return; + } + + if (this.spawningRandom.nextDouble() < this.addon.getSettings().getGuardianChance()) + { + this.summonEntity(event.getLocation(), EntityType.GUARDIAN); + event.setCancelled(true); } } @@ -161,11 +216,7 @@ else if (world.getEnvironment() == World.Environment.NORMAL && event.getEntity() */ private boolean isSuitableNetherLocation(Location location) { - Material material = location.getBlock().getRelative(BlockFace.DOWN).getType(); - - return material == Material.NETHER_BRICKS || - material == Material.NETHER_BRICK_SLAB || - material == Material.NETHER_BRICK_STAIRS; + return NETHER_BRICKS.contains(location.getBlock().getRelative(BlockFace.DOWN).getType()); } @@ -176,11 +227,7 @@ private boolean isSuitableNetherLocation(Location location) */ private boolean isSuitableEndLocation(Location location) { - Material material = location.getBlock().getRelative(BlockFace.DOWN).getType(); - - return material == Material.PURPUR_BLOCK || - material == Material.PURPUR_SLAB || - material == Material.PURPUR_STAIRS; + return PURPUR_BLOCKS.contains(location.getBlock().getRelative(BlockFace.DOWN).getType()); } @@ -191,26 +238,14 @@ private boolean isSuitableEndLocation(Location location) */ private boolean isSuitableGuardianLocation(Location location) { - // Current block Block block = location.getBlock(); - while (block != null && block.getType() == Material.WATER) + while (block.getType() == Material.WATER) { - // Find first top block that is not a water. block = block.getRelative(BlockFace.UP); } - Material material = block.getType(); - - return material == Material.PRISMARINE || - material == Material.PRISMARINE_SLAB || - material == Material.PRISMARINE_STAIRS || - material == Material.PRISMARINE_BRICKS || - material == Material.PRISMARINE_BRICK_SLAB || - material == Material.PRISMARINE_BRICK_STAIRS || - material == Material.DARK_PRISMARINE || - material == Material.DARK_PRISMARINE_SLAB || - material == Material.DARK_PRISMARINE_STAIRS; + return PRISMARINE_BLOCKS.contains(block.getType()); } @@ -242,8 +277,8 @@ private boolean applyGameModeReplacements( for (var rule : rules) { - org.bukkit.entity.EntityType oldType = rule.resolveOldEntityType(); - org.bukkit.entity.EntityType newType = rule.resolveNewEntityType(); + EntityType oldType = rule.resolveOldEntityType(); + EntityType newType = rule.resolveNewEntityType(); if (oldType == null || newType == null) { diff --git a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java index 0471111..7ec6081 100644 --- a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java +++ b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java @@ -1,5 +1,7 @@ package world.bentobox.extramobs.listeners; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -509,9 +511,9 @@ void testPerGameModeWorldReplacement() { void testGetReplacementsNullInputs() { Settings s = new Settings(); // All null/empty paths should return empty list without NPE - assert s.getReplacements(null, "nether").isEmpty(); - assert s.getReplacements("BSkyBlock", null).isEmpty(); - assert s.getReplacements("BSkyBlock", "nether").isEmpty(); + assertTrue(s.getReplacements(null, "nether").isEmpty()); + assertTrue(s.getReplacements("BSkyBlock", null).isEmpty()); + assertTrue(s.getReplacements("BSkyBlock", "nether").isEmpty()); } @Test @@ -532,10 +534,10 @@ void testGetReplacementsParsesRawMap() { s.setGamemodeSettings(raw); List result = s.getReplacements("BSkyBlock", "nether"); - assert result.size() == 1; + assertEquals(1, result.size()); MobSpawnReplacement r = result.get(0); - assert r.resolveOldEntityType() == EntityType.ZOMBIFIED_PIGLIN; - assert r.resolveNewEntityType() == EntityType.WITHER_SKELETON; - assert Math.abs(r.getChance() - 0.05) < 1e-9; + assertEquals(EntityType.ZOMBIFIED_PIGLIN, r.resolveOldEntityType()); + assertEquals(EntityType.WITHER_SKELETON, r.resolveNewEntityType()); + assertEquals(0.05, r.getChance(), 1e-9); } } From a96a0992ae8d8d4f0ab868d9b52a90da886b2a3f Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 17:51:16 -0700 Subject: [PATCH 18/19] Wire Jacoco agent into surefire argLine Surefire's was hard-coded with the --add-opens flags only, which overrode the ${argLine} property that jacoco-maven-plugin:prepare-agent sets to attach the coverage agent. As a result no jacoco.exec was ever produced and SonarCloud reported 0% coverage despite the suite running. Prepending @{argLine} lets Maven late-bind the Jacoco-supplied javaagent flag alongside the existing --add-opens, so coverage now lands in target/site/jacoco/jacoco.xml where the Sonar scanner picks it up. Co-Authored-By: Claude Opus 4.7 (1M context) --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index c6c3a26..155982f 100644 --- a/pom.xml +++ b/pom.xml @@ -248,6 +248,7 @@ **/*Test??.java + @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.math=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED From 51e3e904d397d131b55e8527d1f55bba92e06c21 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 30 May 2026 18:20:06 -0700 Subject: [PATCH 19/19] Address Copilot review comments on PR #20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MobSpawnReplacement.resolveOldEntityType / resolveNewEntityType: use toUpperCase(Locale.ROOT) so config parsing stays stable across server locales (Turkish 'i' vs 'I' problem). Reviewer comments at MobSpawnReplacement.java:67 and :90. - config.yml + README: rewrite the per-gamemode override note to describe the actual fallback-on-miss semantics. The current listener falls through to global chances when no per-gamemode rule fires (wrong entity / chance roll failed), and that behaviour is enshrined by testPerGameModeNetherChanceZeroFallsBackToGlobal / testPerGameModeNetherReplacementEntityMismatchFallsBackToGlobal — the docs were wrong, not the code. Reviewer comments at config.yml:31 and README.md:78. - CLAUDE.md: refresh stale build/test guidance for the 1.15.0 toolchain (Java 21 / Paper 1.21.11 / BentoBox 3.14.0-SNAPSHOT, MockBukkit + JUnit 5, Pladdon entry point, per-gamemode rule semantics with the test names to look for before changing the listener). Reviewer comment at CLAUDE.md:15. - .github/workflows/build.yml: split mvn verify from the Sonar invocation, gate the Sonar step on the PR coming from the same repo so PRs from forks (where SONAR_TOKEN is not exposed) still get a green Maven build instead of failing during analysis. Reviewer comment at build.yml:38. 42/42 tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 10 ++++++++-- CLAUDE.md | 18 +++++++++++------- README.md | 2 +- .../extramobs/config/MobSpawnReplacement.java | 6 ++++-- src/main/resources/config.yml | 8 ++++++-- 5 files changed, 30 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e38f7e1..68a1cbb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,8 +31,14 @@ jobs: path: ~/.m2 key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-m2 - - name: Build and analyze + - name: Build with Maven + run: mvn -B verify + - name: Analyze with SonarCloud + # SONAR_TOKEN is not exposed to PRs from forks, so skip the Sonar step + # in that case rather than failing the workflow. Pushes and PRs from + # the same repo still get analysed. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=BentoBoxWorld_ExtraMobs + run: mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=BentoBoxWorld_ExtraMobs diff --git a/CLAUDE.md b/CLAUDE.md index 8a7eb28..df589d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -ExtraMobs is a BentoBox addon (Bukkit/Spigot plugin) that re-skins certain natural mob spawns inside GameMode-managed worlds: Zombified Piglins → Blaze/Wither Skeleton in the Nether, Endermen → Shulkers in the End, and Fish → Guardians in deep-ocean overworld biomes. The addon does not alter Minecraft's spawn rules — it listens for natural spawns and conditionally cancels + re-spawns a different entity. +ExtraMobs is a BentoBox addon (Paper plugin) that re-skins certain natural mob spawns inside GameMode-managed worlds: Zombified Piglins / Piglins → Blaze/Wither Skeleton in the Nether, Endermen → Shulkers in the End, and Fish → Guardians in deep-ocean overworld biomes. The addon does not alter Minecraft's spawn rules — it listens for natural spawns and conditionally cancels + re-spawns a different entity. As of 1.15.0 it also ships a Pladdon entry point so it can be loaded directly by Paper as well as via BentoBox. ## Build & test @@ -12,23 +12,27 @@ ExtraMobs is a BentoBox addon (Bukkit/Spigot plugin) that re-skins certain natur - `mvn test` — runs the test suite. - `mvn test -Dtest=MobsSpawnListenerTest` — single test class. - `mvn test -Dtest=MobsSpawnListenerTest#methodName` — single test method. -- Java 17 (`17` in pom.xml). Targets Spigot 1.21.3 and BentoBox 2.7.1-SNAPSHOT. +- Java 21 (`21` in pom.xml). Targets Paper 1.21.11 and BentoBox 3.14.0-SNAPSHOT. - Build versioning is driven by Maven profiles: `-LOCAL` by default, `-b` under Jenkins CI, and a clean release version when `GIT_BRANCH=origin/master`. Don't hand-edit version strings — change `build.version` in `pom.xml`. ## Architecture -Tiny codebase, three production classes: +Tiny codebase, four production classes: - `ExtraMobsAddon` (`src/main/java/world/bentobox/extramobs/`) — extends `world.bentobox.bentobox.api.addons.Addon`. In `onLoad()` it loads `Settings` via BentoBox's `Config<>` (auto-creates `config.yml` from `src/main/resources/`). In `onEnable()` it iterates `getAddonsManager().getGameModeAddons()`, sets `hooked=true` if any GameMode is not in `disabledGameModes`, and registers `MobsSpawnListener`. If nothing hooks, the addon disables itself. -- `config.Settings` — `ConfigObject` with `@StoreAt(filename="config.yml", path="addons/ExtraMobs")`. Field annotations (`@ConfigEntry`, `@ConfigComment`) drive both YAML parsing and the on-disk comment block; getters/setters are mandatory for the BentoBox config framework to bind values. -- `listeners.MobsSpawnListener` — single `@EventHandler(priority=HIGHEST, ignoreCancelled=true)` on `CreatureSpawnEvent`. Only `SpawnReason.NATURAL` events are considered. Flow: resolve the GameMode via `plugin.getIWM().getAddon(world)`, bail if disabled, then branch by entity type + environment (`isIslandNether` / `isIslandEnd` / `World.Environment.NORMAL` + biome). Each branch checks a "suitable block" predicate (nether brick / purpur / prismarine, with slab+stairs variants) and rolls against the configured chance. On a successful roll the event is cancelled and `world.spawnEntity()` summons the replacement. +- `ExtraMobsPladdon` — extends `Pladdon`. Lets Paper load the addon directly as a plugin (paired with `src/main/resources/plugin.yml`); returns a fresh `ExtraMobsAddon` from `getAddon()`. +- `config.Settings` — `ConfigObject` with `@StoreAt(filename="config.yml", path="addons/ExtraMobs")`. Field annotations (`@ConfigEntry`, `@ConfigComment`) drive both YAML parsing and the on-disk comment block; getters/setters are mandatory for the BentoBox config framework to bind values. The `gamemode-settings` field is stored as `Map` because BentoBox's config layer doesn't model the nested-list-of-maps shape; `getReplacements(gameMode, env)` parses the raw structure into `MobSpawnReplacement` records on read. +- `config.MobSpawnReplacement` — POJO for one per-gamemode rule (`old` mob, `new` mob, `chance`). `resolveOldEntityType()` / `resolveNewEntityType()` upper-case via `Locale.ROOT` then call `EntityType.valueOf` defensively. +- `listeners.MobsSpawnListener` — single `@EventHandler(priority=HIGHEST, ignoreCancelled=true)` on `CreatureSpawnEvent`. Only `SpawnReason.NATURAL` events are considered. Flow: `resolveActiveGameMode(world)` returns the GameMode name (or `null` if absent / disabled), then `onEntitySpawn` dispatches by entity type + environment to `handleNetherSpawn` / `handleEndSpawn` / `handleOverworldSpawn`. Each handler first checks a "suitable block" predicate (nether brick / purpur / prismarine, with slab+stairs variants), then calls `applyGameModeReplacements` for per-gamemode rules, and only falls back to the global `nether-chances` / `end-chances` / `overworld-chance` values if no per-gamemode rule fires for that event. The four block sets are pre-computed `Set` statics on the class. The "suitable location" helpers encode the design rule that drives the addon: replacement is gated on the player having built a themed structure. Changes to spawn rules almost always live in these predicates plus the dispatch branches in `onEntitySpawn`. +Per-gamemode rules **supplement** the globals rather than suppress them — a per-gamemode rule with `chance: 0.05` for `ZOMBIFIED_PIGLIN→WITHER_SKELETON` falls through to the global wither/blaze chances on a miss, and `PIGLIN` (different entity) always falls through. The tests `testPerGameModeNetherChanceZeroFallsBackToGlobal` and `testPerGameModeNetherReplacementEntityMismatchFallsBackToGlobal` enshrine this behaviour — keep it in mind before changing the listener. + ## Testing notes -- JUnit 4 + Mockito + PowerMock (`@RunWith(PowerMockRunner.class)`). Bukkit's static `Server`/`Registry`/`Tag` are stubbed via `listeners/mocks/ServerMocks.newServer()` — call this in `@Before` whenever a test touches Bukkit statics. The surefire plugin's long `--add-opens` argLine is required for PowerMock under Java 17; don't strip it. -- Jacoco excludes `**/*Names*` and `org/bukkit/Material*` to avoid synthetic-field / "Material too large to mock" failures. New tests that need to mock `Material` should rely on `ServerMocks` rather than reintroducing PowerMock static-mocking on `Material`. +- JUnit 5 + Mockito 5 + MockBukkit (`org.mockbukkit.mockbukkit:mockbukkit-v1.21`). Test classes extend `CommonTestSetup` which calls `MockBukkit.mock()` in `@BeforeEach` and tears down in `@AfterEach`; it also injects the `BentoBox` singleton via `WhiteBox.setInternalState(BentoBox.class, "instance", plugin)` and statically stubs `Bukkit` + `Util`. The surefire plugin's long `--add-opens` argLine plus the leading `@{argLine}` (which late-binds the Jacoco prepare-agent javaagent) are required; don't strip either. +- Jacoco excludes `**/*Names*` and `org/bukkit/Material*` to avoid synthetic-field / "Material too large to mock" failures. Coverage is reported to SonarCloud via the GitHub Actions workflow. ## Resources & packaging diff --git a/README.md b/README.md index 4840b41..b8082c0 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ All chance values are decimals between `0.0` (never) and `1.0` (always). For the ### Per-Gamemode Overrides -You can define replacement rules that only apply to a specific game mode. Each gamemode may set rules for the `nether`, `end`, and/or `world` (overworld) environments. **When per-gamemode rules are present for an environment, the global defaults above are not applied for that gamemode.** +You can define replacement rules that only apply to a specific game mode. Each gamemode may set rules for the `nether`, `end`, and/or `world` (overworld) environments. Rules are tried in order before the global defaults. **If a rule matches the spawning entity and its chance roll succeeds, the replacement is applied and processing stops; otherwise the global defaults above are used as a fallback** — so per-gamemode rules supplement the globals rather than replace them, and you can keep the globals as a safety net for any entity the per-gamemode block doesn't cover. ```yaml gamemode-settings: diff --git a/src/main/java/world/bentobox/extramobs/config/MobSpawnReplacement.java b/src/main/java/world/bentobox/extramobs/config/MobSpawnReplacement.java index d588375..0d66dfe 100644 --- a/src/main/java/world/bentobox/extramobs/config/MobSpawnReplacement.java +++ b/src/main/java/world/bentobox/extramobs/config/MobSpawnReplacement.java @@ -1,6 +1,8 @@ package world.bentobox.extramobs.config; +import java.util.Locale; + import org.bukkit.entity.EntityType; @@ -64,7 +66,7 @@ public EntityType resolveOldEntityType() } try { - return EntityType.valueOf(old.toUpperCase()); + return EntityType.valueOf(old.toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException e) { @@ -87,7 +89,7 @@ public EntityType resolveNewEntityType() } try { - return EntityType.valueOf(newMob.toUpperCase()); + return EntityType.valueOf(newMob.toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException e) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 58fb817..c95e810 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -27,8 +27,12 @@ overworld-chance: # old: EntityType name of the mob to replace (e.g. ZOMBIFIED_PIGLIN) # new: EntityType name of the replacement mob (e.g. WITHER_SKELETON) # chance: Probability in the range 0.0-1.0 -# When per-gamemode rules are present for an environment, the global defaults -# for that environment are NOT applied to that gamemode. +# Per-gamemode rules are tried in order before the global defaults for that +# environment. If a rule matches the spawning entity AND its chance roll +# succeeds, the replacement is applied and processing stops for that event. +# If no rule matches (different entity), or every matching rule's chance roll +# fails, the global nether-chances / end-chances / overworld-chance values +# above are applied as a fallback. # Example: # gamemode-settings: # BSkyBlock: