diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..68a1cbb --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,44 @@ +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 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 org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=BentoBoxWorld_ExtraMobs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..df589d2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,39 @@ +# 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 (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 + +- `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 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, 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. +- `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 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 + +`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. diff --git a/README.md b/README.md index cd5bee4..b8082c0 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. 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: + 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). diff --git a/pom.xml b/pom.xml index 5331fcd..155982f 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 @@ -52,15 +55,18 @@ X.Y.Z -> BentoBox core version .M -> Addon development iteration. --> - 1.14.0 + 1.15.0 -LOCAL + + bentobox-world + https://sonarcloud.io - ci @@ -74,13 +80,13 @@ - - - - master @@ -100,66 +106,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 +231,41 @@ 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} + @{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 +284,8 @@ false -Xdoclint:none - ${java.home}/bin/javadoc + 21 @@ -298,6 +320,11 @@ maven-deploy-plugin 2.8.2 + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + org.jacoco jacoco-maven-plugin @@ -305,7 +332,7 @@ true - **/*Names* 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/java/world/bentobox/extramobs/config/MobSpawnReplacement.java b/src/main/java/world/bentobox/extramobs/config/MobSpawnReplacement.java new file mode 100644 index 0000000..0d66dfe --- /dev/null +++ b/src/main/java/world/bentobox/extramobs/config/MobSpawnReplacement.java @@ -0,0 +1,195 @@ +package world.bentobox.extramobs.config; + + +import java.util.Locale; + +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(Locale.ROOT)); + } + 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(Locale.ROOT)); + } + 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..51a212e 100644 --- a/src/main/java/world/bentobox/extramobs/config/Settings.java +++ b/src/main/java/world/bentobox/extramobs/config/Settings.java @@ -1,7 +1,12 @@ 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.Optional; import java.util.Set; import world.bentobox.bentobox.api.configuration.ConfigComment; @@ -136,6 +141,111 @@ 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) + { + 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 // --------------------------------------------------------------------- @@ -164,4 +274,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..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,89 +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; + } - 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 handleNetherSpawn(CreatureSpawnEvent event, String gameModeName) + { + if (!this.isSuitableNetherLocation(event.getLocation())) { - // 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)) + if (this.applyGameModeReplacements(event, gameModeName, "nether")) { + return; + } - // replace pigmen with blaze or wither + 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); + } + } - if (this.isSuitableNetherLocation(event.getLocation())) - { - 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); - } - } + private void handleEndSpawn(CreatureSpawnEvent event, String gameModeName) + { + if (!this.isSuitableEndLocation(event.getLocation())) + { + return; } - else if (event.getEntityType() == EntityType.ENDERMAN && - this.addon.getPlugin().getIWM().isIslandEnd(world)) + + if (this.applyGameModeReplacements(event, gameModeName, "end")) { - // replace enderman with shulker - if (this.isSuitableEndLocation(event.getLocation())) - { - 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.spawningRandom.nextDouble() < this.addon.getSettings().getShulkerChance()) { + this.summonEntity(event.getLocation(), EntityType.SHULKER); + event.setCancelled(true); + } + } - // 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.spawningRandom.nextDouble() < this.addon.getSettings().getGuardianChance()) - { - // oOo guardian got lucky. - this.summonEntity(event.getLocation(), EntityType.GUARDIAN); - event.setCancelled(true); - } - } - } + 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())) + { + return; + } + + if (this.applyGameModeReplacements(event, gameModeName, "world")) + { + return; + } + + if (this.spawningRandom.nextDouble() < this.addon.getSettings().getGuardianChance()) + { + this.summonEntity(event.getLocation(), EntityType.GUARDIAN); + event.setCancelled(true); } } @@ -145,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()); } @@ -160,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()); } @@ -175,26 +238,63 @@ 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 PRISMARINE_BLOCKS.contains(block.getType()); + } + + + /** + * 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) + { + EntityType oldType = rule.resolveOldEntityType(); + 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 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 false; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4fc8c28..c95e810 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -15,4 +15,45 @@ 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 +# 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: +# 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/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} 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..ee848af --- /dev/null +++ b/src/test/java/world/bentobox/extramobs/ExtraMobsAddonTest.java @@ -0,0 +1,187 @@ +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.junit.jupiter.api.Assertions.assertTrue; +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 + gamemode-settings: {} + """; + + @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); + } + + @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/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..7ec6081 100644 --- a/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java +++ b/src/test/java/world/bentobox/extramobs/listeners/MobsSpawnListenerTest.java @@ -1,13 +1,19 @@ 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.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.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import org.bukkit.Location; import org.bukkit.Material; @@ -16,155 +22,522 @@ 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.MobSpawnReplacement; 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); + // 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(); + 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); + } + + @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(plugin.getIWM()).thenReturn(iwm); + listener.onEntitySpawn(event); - when(iwm.getAddon(world)).thenReturn(Optional.of(gma)); + verify(world, never()).spawnEntity(any(Location.class), any(EntityType.class)); + } - when(iwm.isIslandEnd(world)).thenReturn(true); + @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)); + } + + // ── 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 + assertTrue(s.getReplacements(null, "nether").isEmpty()); + assertTrue(s.getReplacements("BSkyBlock", null).isEmpty()); + assertTrue(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"); + assertEquals(1, result.size()); + MobSpawnReplacement r = result.get(0); + assertEquals(EntityType.ZOMBIFIED_PIGLIN, r.resolveOldEntityType()); + assertEquals(EntityType.WITHER_SKELETON, r.resolveNewEntityType()); + assertEquals(0.05, r.getChance(), 1e-9); + } } 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