diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 06898a5..daf47b5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,4 +35,4 @@ jobs: 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 \ No newline at end of file + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=BentoBoxWorld_Limits diff --git a/.github/workflows/modrinth-publish.yml b/.github/workflows/modrinth-publish.yml index 350c3c8..2173242 100644 --- a/.github/workflows/modrinth-publish.yml +++ b/.github/workflows/modrinth-publish.yml @@ -87,6 +87,9 @@ jobs: 1.21.9 1.21.10 1.21.11 + 26.1 + 26.1.1 + 26.1.2 # Path to the built JAR — Maven produces Limits-.jar in target/ # Note: glob patterns are not supported; use the release tag directly. diff --git a/CLAUDE.md b/CLAUDE.md index 73092fc..fbf4940 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,15 +36,34 @@ Limits is a **BentoBox addon** (not a standalone Spigot plugin). It depends on B 4. Registers three listeners: `BlockLimitsListener`, `JoinListener`, `EntityLimitListener` 5. Registers PlaceholderAPI placeholders for each material and entity type -### Three-Tier Limit System +### Per-Environment Tracking (since #43) -Limits are resolved in a priority hierarchy (highest wins): +Block counts, entity counts, limits, and offsets are all tracked **per `World.Environment`** (overworld, nether, end). The data model in `IslandBlockCount` keys every map by `Environment` first, then by material/entity. Pre-env data on disk is migrated into `Environment.NORMAL` lazily on first access. -1. **Island-specific** (`IslandBlockCount.blockLimits` / `entityLimits`): set by player permissions, checked at join -2. **World-specific** (`BlockLimitsListener.worldLimitMap`): from `config.yml` `worlds:` section -3. **Default** (`BlockLimitsListener.defaultLimitMap`): from `config.yml` `blocklimits:` section +This means: +- Counts in nether and end are persistent — they don't go to zero when chunks unload (the original bug in #43). +- A single config-defined limit applies independently to each env (`HOPPER: 10` allows 10 in overworld, 10 in nether, 10 in end). +- Entity portal teleports decrement the source-env count and increment the destination-env count via `EntityPortalEvent`. -**Offsets** (`blockLimitsOffset`, `entityLimitsOffset`) are added on top of any limit found — used by the `offset` admin command to give per-island bonus allowances. +### Limit Resolution Order + +For a block placement (or entity spawn) in environment `env`, the limit is resolved in priority order: + +1. **Island-specific env limit** (`IslandBlockCount.envBlockLimits[env]`) — set by player permissions, checked at join. +2. **World-specific limit** (`BlockLimitsListener.worldLimitMap[world]`) — from the `worlds:` section. Each world is one env so this is implicitly env-scoped. +3. **Env default** (`BlockLimitsListener.envDefaultLimitMap[env]`) — from `blocklimits` (seeded into all envs) plus `blocklimits-nether` / `blocklimits-end` overrides. + +**Offsets** (`envBlockLimitsOffset[env]`, `envEntityLimitsOffset[env]`) are added on top of any resolved limit. The legacy `/offset` admin command sets the same offset across all envs via the `setBlockLimitsOffsetAllEnvs` / `setEntityLimitsOffsetAllEnvs` helpers. + +### Persistent Entity Counts + +`EntityLimitListener` no longer counts entities via `World.getNearbyEntities(island.getBoundingBox())`. Instead it maintains the count in `IslandBlockCount.envEntityCounts`: + +- Increment on MONITOR-priority handlers for `CreatureSpawnEvent`, `VehicleCreateEvent`, `HangingPlaceEvent` (after our LOW-priority limit check has had its say). +- Decrement on `EntityRemoveEvent` whenever the cause is **not** `UNLOAD` (so death, despawn, drop, plugin removal, etc. all decrement; chunk unload does not). +- `EntityPortalEvent` (MONITOR) handles env-to-env teleport: decrement source env, increment destination env. + +This is Paper-only (the addon dropped Spigot support; `EntityRemoveEvent` is on Bukkit's API now but `EntityRemoveEvent.Cause` is needed). ### Key Classes @@ -52,7 +71,8 @@ Limits are resolved in a priority hierarchy (highest wins): |---|---| | `Limits` | Main addon; wires everything together, registers placeholders | | `Settings` | Parses `config.yml`; holds entity limits, entity group limits, game mode list | -| `IslandBlockCount` | Per-island data object stored in BentoBox's database (`@Table("IslandBlockCount")`); tracks block counts, per-island block/entity limits, and offsets | +| `IslandBlockCount` | Per-island data object stored in BentoBox's database (`@Table("IslandBlockCount")`); tracks block counts, entity counts, limits, and offsets — all keyed by `Environment` | +| `EnvNamespacedKeyMapAdapter` | Gson type adapter for `Map>` — needed because `NamespacedKey` is not enum-keyed and Gson can't handle it natively | | `BlockLimitsListener` | Core block tracking listener; handles all block place/break/grow/explode events; maintains the `islandCountMap` in memory; persists via BentoBox `Database` | | `JoinListener` | Applies permission-based limits on player join, island creation/reset, and ownership change; fires `LimitsPermCheckEvent` / `LimitsJoinPermCheckEvent` for external cancellation | | `EntityLimitListener` | Cancels entity spawn/breed/vehicle-create events when entity or group limits are exceeded; handles async golem/snowman spawning edge cases | @@ -60,16 +80,30 @@ Limits are resolved in a priority hierarchy (highest wins): ### Permission-Based Limits -Permission format: `.island.limit..` - -Example: `bskyblock.island.limit.HOPPER.20` +Two formats: +- 5-segment, all-env: `.island.limit..` — applied independently to overworld, nether, and end. +- 6-segment, env-scoped: `.island.limit...` where `` ∈ `{overworld, nether, end}`. -`JoinListener.checkPerms()` clears all permission-based limits then re-applies them from scratch on each join. The highest value wins if multiple permissions grant the same limit. Wildcards are not supported. +`JoinListener.checkPerms()` clears all permission-based limits then re-applies them from scratch on each join. The highest value wins if multiple permissions grant the same limit for the same env. Wildcards are not supported. ### Block Material Normalization `BlockLimitsListener.fixMaterial()` normalizes variant materials to their canonical form (e.g., `CHIPPED_ANVIL` → `ANVIL`, `REDSTONE_WALL_TORCH` → `REDSTONE_TORCH`, `PISTON_HEAD` → `PISTON`/`STICKY_PISTON`). Block config keys can use Bukkit Material names or Minecraft tag names (e.g., `minecraft:logs`). +### Block State Transitions + +When one block becomes another in place — `BlockFormEvent`, `EntityBlockFormEvent`, `BlockSpreadEvent` (grass/mycelium/fire spread), `BlockGrowEvent` (crops, sugar cane, etc.) — the handlers must keep the count balanced: **decrement the replaced block, then add the new one with a limit check, and revert on cancel.** The canonical shape (see `onBlock(BlockFormEvent)`): + +```java +process(e.getBlock(), false); // remove old +if (process(e.getBlock(), e.getNewState().getBlockData(), true) > -1) { + e.setCancelled(true); + process(e.getBlock(), true); // limit hit → restore old +} +``` + +Key invariant when editing these handlers: `process(block, true)` returns the limit **before** incrementing when the limit is hit, so it only ever adds to the count on the success path (`-1` return). Do **not** add a compensating `process(..., false)` in the cancel branch — nothing was added, so it would decrement a real block's count below its true value and let the next transition slip past the limit. `BlockLimitsListenerTest` guards this with the `*StateTransition`, `*AtLimitCancelsAndRestoresOld`, and `testBlockGrowAtNonZeroLimitDoesNotDecrement` cases. + ### Events API Two cancellable events are fired so other plugins can intercept limit application: @@ -146,3 +180,92 @@ worldLimitField.setAccessible(true); #### Testing Debounce (`justSpawned`) `EntityLimitListener`'s private `justSpawned` list (deduplicates entity spawns) can be accessed via reflection to test debounce behavior. + +## Dependency Source Lookup + +When you need to inspect source code for a dependency (e.g., BentoBox, addons): + +1. **Check local Maven repo first**: `~/.m2/repository/` — sources jars are named `*-sources.jar` +2. **Check the workspace**: Look for sibling directories or Git submodules that may contain the dependency as a local project (e.g., `../bentoBox`, `../addon-*`) +3. **Check Maven local cache for already-extracted sources** before downloading anything +4. Only download a jar or fetch from the internet if the above steps yield nothing useful + +Prefer reading `.java` source files directly from a local Git clone over decompiling or extracting a jar. + +In general, the latest version of BentoBox should be targeted. + +## Project Layout + +Related projects are checked out as siblings under `~/git/`: + +**Core:** +- `bentobox/` — core BentoBox framework + +**Game modes:** +- `addon-acidisland/` — AcidIsland game mode +- `addon-bskyblock/` — BSkyBlock game mode +- `Boxed/` — Boxed game mode (expandable box area) +- `CaveBlock/` — CaveBlock game mode +- `OneBlock/` — AOneBlock game mode +- `SkyGrid/` — SkyGrid game mode +- `RaftMode/` — Raft survival game mode +- `StrangerRealms/` — StrangerRealms game mode +- `Brix/` — plot game mode +- `parkour/` — Parkour game mode +- `poseidon/` — Poseidon game mode +- `gg/` — gg game mode + +**Addons:** +- `addon-level/` — island level calculation +- `addon-challenges/` — challenges system +- `addon-welcomewarpsigns/` — warp signs +- `addon-limits/` — block/entity limits +- `addon-invSwitcher/` / `invSwitcher/` — inventory switcher +- `addon-biomes/` / `Biomes/` — biomes management +- `Bank/` — island bank +- `Border/` — world border for islands +- `Chat/` — island chat +- `CheckMeOut/` — island submission/voting +- `ControlPanel/` — game mode control panel +- `Converter/` — ASkyBlock to BSkyBlock converter +- `DimensionalTrees/` — dimension-specific trees +- `discordwebhook/` — Discord integration +- `Downloads/` — BentoBox downloads site +- `DragonFights/` — per-island ender dragon fights +- `ExtraMobs/` — additional mob spawning rules +- `FarmersDance/` — twerking crop growth +- `GravityFlux/` — gravity addon +- `Greenhouses-addon/` — greenhouse biomes +- `IslandFly/` — island flight permission +- `IslandRankup/` — island rankup system +- `Likes/` — island likes/dislikes +- `Limits/` — block/entity limits +- `lost-sheep/` — lost sheep adventure +- `MagicCobblestoneGenerator/` — custom cobblestone generator +- `PortalStart/` — portal-based island start +- `pp/` — pp addon +- `Regionerator/` — region management +- `Residence/` — residence addon +- `TopBlock/` — top ten for OneBlock +- `TwerkingForTrees/` — twerking tree growth +- `Upgrades/` — island upgrades (Vault) +- `Visit/` — island visiting +- `weblink/` — web link addon +- `CrowdBound/` — CrowdBound addon + +**Data packs:** +- `BoxedDataPack/` — advancement datapack for Boxed + +**Documentation & tools:** +- `docs/` — main documentation site +- `docs-chinese/` — Chinese documentation +- `docs-french/` — French documentation +- `BentoBoxWorld.github.io/` — GitHub Pages site +- `website/` — website +- `translation-tool/` — translation tool + +Check these for source before any network fetch. + +## Key Dependencies (source locations) + +- `world.bentobox:bentobox` → `~/git/bentobox/src/` diff --git a/README.md b/README.md index 69231dd..d4a1f1a 100644 --- a/README.md +++ b/README.md @@ -19,32 +19,54 @@ There is a user command and an admin command called "limits". Admins can check t The config.yml has the following sections: -* blocklimits -* worlds -* entitylimits +* `blocklimits`, `blocklimits-nether`, `blocklimits-end` +* `worlds` +* `entitylimits`, `entitylimits-nether`, `entitylimits-end` +* `entitygrouplimits`, `entitygrouplimits-nether`, `entitygrouplimits-end` + +### Per-environment separation + +Limits are tracked independently in the overworld, nether, and end. A `HOPPER: 10` limit means the player can place up to 10 hoppers in **each** of overworld, nether, and end — 30 hoppers total across the island. Likewise for entities and entity groups. + +If you want a different limit in nether or end, add a corresponding `*-nether` or `*-end` section that overrides the env-default for those entries only. ### blocklimits -This section lists the maximum number of blocks allowed for each block material. Do not use non-block materials because they will not work. The limits apply to all game worlds. +The base block-limit section. Values here apply to every environment unless overridden by `blocklimits-nether` / `blocklimits-end` or by a `worlds.` entry. ### worlds -This section lists block limits for specific worlds. You must name the world specifically, e.g. AcidIsland_world and then list the materials and the limit. +Per-world overrides. Name the world specifically (e.g. `AcidIsland_world`) and list the materials and limits. A world entry overrides the env-default for that exact world. ### entitylimits -Coming soon! +Default entity-type limits applied independently per environment. Override per env via `entitylimits-nether` / `entitylimits-end`. + +### entitygrouplimits + +Define named entity groups (icon, member set, default limit). Override the limit value per env with `entitygrouplimits-nether` / `entitygrouplimits-end`. The icon and member set are fixed in the base section — only the numeric limit can be overridden per env. ## Permissions -Island owners can have exclusive permissions that override the default or world settings. The format is: +Island owners can have exclusive permissions that override the default or world settings. Two formats are supported: -Format is `GAME-MODE-NAME.island.limit.MATERIAL.LIMIT` +* All-environment: `.island.limit..` — applies the limit independently to every environment. +* Per-environment: `.island.limit...` — applies only to one environment, where `` is `overworld`, `nether`, or `end`. -example: `bskyblock.island.limit.hopper.10` +Examples: + +``` +bskyblock.island.limit.hopper.10 # 10 hoppers in each env +bskyblock.island.limit.nether.hopper.5 # only 5 hoppers in nether +bskyblock.island.limit.end.enderman.50 # only 50 endermen in the end +``` Permissions activate when the player logs in. +### Migrating an existing server + +When upgrading from a pre-env version of Limits, existing per-island block counts are migrated to the overworld slot on first load. Counts in the nether/end will start at zero and be populated as entities and blocks change. Run `/ admin limits calc ` to fully recount any island and redistribute its counts across environments. + Usage permissions are (put the gamemode name, e.g. acidisland at the front): ``` diff --git a/pom.xml b/pom.xml index dde6857..4b401b0 100644 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,7 @@ -LOCAL - 1.28.1 + 1.28.2 BentoBoxWorld_Limits bentobox-world https://sonarcloud.io @@ -135,9 +135,9 @@ - com.github.MockBukkit - MockBukkit - v1.21-SNAPSHOT + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.110.0 test diff --git a/src/main/java/world/bentobox/limits/Limits.java b/src/main/java/world/bentobox/limits/Limits.java index 68129d2..43f7b04 100644 --- a/src/main/java/world/bentobox/limits/Limits.java +++ b/src/main/java/world/bentobox/limits/Limits.java @@ -1,316 +1,255 @@ -package world.bentobox.limits; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -import org.bukkit.Material; -import org.bukkit.NamespacedKey; -import org.bukkit.Registry; -import org.bukkit.World; -import org.bukkit.entity.Entity; -import org.bukkit.entity.EntityType; -import org.eclipse.jdt.annotation.Nullable; - -import world.bentobox.bentobox.api.addons.Addon; -import world.bentobox.bentobox.api.addons.GameModeAddon; -import world.bentobox.bentobox.api.user.User; -import world.bentobox.bentobox.database.objects.Island; -import world.bentobox.bentobox.managers.IslandWorldManager; -import world.bentobox.limits.commands.admin.AdminCommand; -import world.bentobox.limits.commands.player.PlayerCommand; -import world.bentobox.limits.listeners.BlockLimitsListener; -import world.bentobox.limits.listeners.EntityLimitListener; -import world.bentobox.limits.listeners.JoinListener; -import world.bentobox.limits.listeners.PaperShulkerLimitListener; -import world.bentobox.limits.objects.IslandBlockCount; - - -/** - * Addon to BentoBox that monitors and enforces limits - * - * @author tastybento - */ -public class Limits extends Addon { - - private static final String LIMIT_NOT_SET = "Limit not set"; - private static final String ISLAND_PLACEHOLDER = "_island_"; - private Settings settings; - private List gameModes = new ArrayList<>(); - private BlockLimitsListener blockLimitListener; - private JoinListener joinListener; - private IslandWorldManager islandWorldManager; - - @Override - public void onDisable() { - if (blockLimitListener != null) { - blockLimitListener.save(); - } - } - - @Override - public void onEnable() { - // Load the plugin's config - saveDefaultConfig(); - this.islandWorldManager = getPlugin().getIWM(); - // Load settings - settings = new Settings(this); - // Register worlds from GameModes - gameModes = getPlugin().getAddonsManager().getGameModeAddons().stream() - .filter(gm -> settings.getGameModes().contains(gm.getDescription().getName())) - .collect(Collectors.toList()); - gameModes.forEach(gm -> - { - // Register commands - gm.getAdminCommand().ifPresent(a -> new AdminCommand(this, a)); - gm.getPlayerCommand().ifPresent(a -> new PlayerCommand(this, a)); - registerPlaceholders(gm); - log("Limits will apply to " + gm.getDescription().getName()); - } - ); - // Register listener - blockLimitListener = new BlockLimitsListener(this); - registerListener(blockLimitListener); - joinListener = new JoinListener(this); - registerListener(joinListener); - EntityLimitListener entityLimitListener = new EntityLimitListener(this); - registerListener(entityLimitListener); - // Register Paper-specific listener for shulker duplication limiting if running on Paper. - // ShulkerDuplicateEvent fires before the original shulker teleports, giving an accurate - // entity count. CreatureSpawnEvent fires after the teleport, so the original shulker may - // have already left the island bounding box, causing the count to be off by one. - try { - Class.forName("io.papermc.paper.event.entity.ShulkerDuplicateEvent"); - registerListener(new PaperShulkerLimitListener(this, entityLimitListener)); - log("Paper detected: shulker duplication limiting active via ShulkerDuplicateEvent."); - } catch (ClassNotFoundException e) { - // Not running on Paper; CreatureSpawnEvent handles duplication on Spigot. - } - // Done - } - - /** - * @return the settings - */ - public Settings getSettings() { - return settings; - } - - /** - * @return the gameModes - */ - public List getGameModes() { - return gameModes; - } - - /** - * @return the blockLimitListener - */ - public BlockLimitsListener getBlockLimitListener() { - return blockLimitListener; - } - - /** - * Checks if this world is covered by the activated game modes - * - * @param world - world - * @return true or false - */ - public boolean inGameModeWorld(World world) { - return gameModes.stream().anyMatch(gm -> gm.inWorld(world)); - } - - /** - * Get the name of the game mode for this world - * - * @param world - world - * @return game mode name or empty string if none - */ - public String getGameModeName(World world) { - return gameModes.stream().filter(gm -> gm.inWorld(world)).findFirst().map(gm -> gm.getDescription().getName()).orElse(""); - } - - /** - * Get the permission prefix for this world - * - * @param world - world - * @return permisdsion prefix or empty string if none - */ - public String getGameModePermPrefix(World world) { - return gameModes.stream().filter(gm -> gm.inWorld(world)).findFirst().map(GameModeAddon::getPermissionPrefix).orElse(""); - } - - - /** - * Check if any of the game modes covered have this name - * - * @param gameMode - name of game mode - * @return true or false - */ - public boolean isCoveredGameMode(String gameMode) { - return gameModes.stream().anyMatch(gm -> gm.getDescription().getName().equals(gameMode)); - } - - /** - * @return the joinListener - */ - public JoinListener getJoinListener() { - return joinListener; - } - - private void registerPlaceholders(GameModeAddon gm) { - if (getPlugin().getPlaceholdersManager() == null) return; - Registry.MATERIAL.stream() - .filter(Material::isBlock) - .forEach(m -> registerCountAndLimitPlaceholders(m.getKey(), gm)); - - Arrays.stream(EntityType.values()) - .forEach(e -> registerCountAndLimitPlaceholders(e, gm)); - } - - /** - * Registers placeholders for the count and limit of the material - * in the format of %Limits_(gamemode prefix)_island_(lowercase material name)_count% - * and %Limits_(gamemode prefix)_island_(lowercase material name)_limit% - *

- * Example: registerCountAndLimitPlaceholders("HOPPER", gm); - * Placeholders: - * "Limits_bskyblock_island_hopper_count" - * "Limits_bskyblock_island_hopper_limit" - * "Limits_bskyblock_island_hopper_base_limit" - * "Limits_bskyblock_island_zombie_limit" - * - * @param m material - * @param gm game mode - */ - private void registerCountAndLimitPlaceholders(NamespacedKey m, GameModeAddon gm) { - getPlugin().getPlaceholdersManager().registerPlaceholder(this, - gm.getDescription().getName().toLowerCase() + ISLAND_PLACEHOLDER + m.toString().toLowerCase() + "_count", - user -> String.valueOf(getCount(user, m, gm))); - getPlugin().getPlaceholdersManager().registerPlaceholder(this, - gm.getDescription().getName().toLowerCase() + ISLAND_PLACEHOLDER + m.toString().toLowerCase() + "_limit", - user -> getLimit(user, m, gm)); - getPlugin().getPlaceholdersManager().registerPlaceholder(this, - gm.getDescription().getName().toLowerCase() + ISLAND_PLACEHOLDER + m.toString().toLowerCase() + "_base_limit", - user -> getBaseLimit(user, m, gm)); - } - - private void registerCountAndLimitPlaceholders(EntityType e, GameModeAddon gm) { - getPlugin().getPlaceholdersManager().registerPlaceholder(this, - gm.getDescription().getName().toLowerCase() + ISLAND_PLACEHOLDER + e.toString().toLowerCase() + "_limit", - user -> getLimit(user, e, gm)); - getPlugin().getPlaceholdersManager().registerPlaceholder(this, - gm.getDescription().getName().toLowerCase() + ISLAND_PLACEHOLDER + e.toString().toLowerCase() + "_base_limit", - user -> getBaseLimit(user, e, gm)); - getPlugin().getPlaceholdersManager().registerPlaceholder(this, - gm.getDescription().getName().toLowerCase() + ISLAND_PLACEHOLDER + e.toString().toLowerCase() + "_count", - user -> String.valueOf(getCount(user, e, gm))); - } - - /** - * @param user - Used to identify the island the user belongs to - * @param m - The material we are trying to count on the island - * @param gm Game Mode Addon - * @return Number of blocks of the specified material on the given user's island - */ - private int getCount(@Nullable User user, NamespacedKey m, GameModeAddon gm) { - Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); - if (is == null) { - return 0; - } - @Nullable IslandBlockCount ibc = getBlockLimitListener().getIsland(is.getUniqueId()); - if (ibc == null) { - return 0; - } - return ibc.getBlockCount(m); - } - - private long getCount(@Nullable User user, EntityType e, GameModeAddon gm) { - Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); - if (is == null || e.getEntityClass() == null) { - return 0; - } - Class entityClass = e.getEntityClass(); - long count = is.getWorld().getEntitiesByClass(entityClass).stream() - .filter(ent -> is.inIslandSpace(ent.getLocation())).count(); - /* NETHER */ - if (islandWorldManager.isNetherIslands(is.getWorld()) && islandWorldManager.getNetherWorld(is.getWorld()) != null) { - count += islandWorldManager.getNetherWorld(is.getWorld()).getEntitiesByClass(entityClass).stream() - .filter(ent -> is.inIslandSpace(ent.getLocation())).count(); - } - /* END */ - if (islandWorldManager.isEndIslands(is.getWorld()) && islandWorldManager.getEndWorld(is.getWorld()) != null) { - count += islandWorldManager.getEndWorld(is.getWorld()).getEntitiesByClass(entityClass).stream() - .filter(ent -> is.inIslandSpace(ent.getLocation())).count(); - } - return count; - } - - - /** - * @param user - Used to identify the island the user belongs to - * @param m - The material whose limit we are querying - * @param gm Game Mode Addon - * @return The limit of the specified material on the given user's island - */ - private String getLimit(@Nullable User user, NamespacedKey m, GameModeAddon gm) { - Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); - if (is == null) { - return LIMIT_NOT_SET; - } - if (user != null) { - // Check the permissions of the user and update - this.getJoinListener().checkPerms(user.getPlayer(), gm.getPermissionPrefix() + "island.limit.", - is.getUniqueId(), gm.getDescription().getName()); - } - int limit = this.getBlockLimitListener(). - getMaterialLimits(is.getWorld(), is.getUniqueId()).getOrDefault(m, -1); - - return limit == -1 ? LIMIT_NOT_SET : String.valueOf(limit); - } - - private String getBaseLimit(@Nullable User user, NamespacedKey m, GameModeAddon gm) { - Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); - if (is == null) { - return LIMIT_NOT_SET; - } - - int limit = this.getBlockLimitListener(). - getMaterialLimits(is.getWorld(), is.getUniqueId()). - getOrDefault(m, -1); - - if (limit > 0) { - limit -= this.getBlockLimitListener().getIsland(is).getBlockLimitOffset(m); - } - - return limit == -1 ? LIMIT_NOT_SET : String.valueOf(limit); - } - - private String getLimit(@Nullable User user, EntityType e, GameModeAddon gm) { - Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); - if (is == null) { - return LIMIT_NOT_SET; - } - - int limit = this.getBlockLimitListener().getIsland(is).getEntityLimit(e); - if (limit < 0 && this.getSettings().getLimits().containsKey(e)) { - limit = this.getSettings().getLimits().get(e); - } - - return limit == -1 ? LIMIT_NOT_SET : String.valueOf(limit); - } - - private String getBaseLimit(@Nullable User user, EntityType e, GameModeAddon gm) { - Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); - if (is == null || !this.getSettings().getLimits().containsKey(e)) { - return LIMIT_NOT_SET; - } - - int limit = this.getSettings().getLimits().get(e); - - return limit == -1 ? LIMIT_NOT_SET : String.valueOf(limit); - } - - -} +package world.bentobox.limits; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.World; +import org.bukkit.World.Environment; +import org.bukkit.entity.EntityType; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.bentobox.api.addons.Addon; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.limits.commands.admin.AdminCommand; +import world.bentobox.limits.commands.player.PlayerCommand; +import world.bentobox.limits.listeners.BlockLimitsListener; +import world.bentobox.limits.listeners.EntityLimitListener; +import world.bentobox.limits.listeners.JoinListener; +import world.bentobox.limits.listeners.PaperShulkerLimitListener; +import world.bentobox.limits.objects.IslandBlockCount; + +/** + * Addon to BentoBox that monitors and enforces limits. + * + * @author tastybento + */ +public class Limits extends Addon { + + private static final String LIMIT_NOT_SET = "Limit not set"; + private static final String ISLAND_PLACEHOLDER = "_island_"; + private Settings settings; + private List gameModes = new ArrayList<>(); + private BlockLimitsListener blockLimitListener; + private JoinListener joinListener; + + @Override + public void onDisable() { + if (blockLimitListener != null) { + blockLimitListener.save(); + } + } + + @Override + public void onEnable() { + saveDefaultConfig(); + settings = new Settings(this); + gameModes = getPlugin().getAddonsManager().getGameModeAddons().stream() + .filter(gm -> settings.getGameModes().contains(gm.getDescription().getName())) + .toList(); + gameModes.forEach(gm -> { + gm.getAdminCommand().ifPresent(a -> new AdminCommand(this, a)); + gm.getPlayerCommand().ifPresent(a -> new PlayerCommand(this, a)); + registerPlaceholders(gm); + log("Limits will apply to " + gm.getDescription().getName()); + }); + blockLimitListener = new BlockLimitsListener(this); + registerListener(blockLimitListener); + joinListener = new JoinListener(this); + registerListener(joinListener); + EntityLimitListener entityLimitListener = new EntityLimitListener(this); + registerListener(entityLimitListener); + try { + Class.forName("io.papermc.paper.event.entity.ShulkerDuplicateEvent"); + registerListener(new PaperShulkerLimitListener(this, entityLimitListener)); + log("Paper detected: shulker duplication limiting active via ShulkerDuplicateEvent."); + } catch (ClassNotFoundException e) { + // Not running on Paper; CreatureSpawnEvent handles duplication on Spigot. + } + } + + public Settings getSettings() { + return settings; + } + + public List getGameModes() { + return gameModes; + } + + public BlockLimitsListener getBlockLimitListener() { + return blockLimitListener; + } + + public boolean inGameModeWorld(World world) { + return gameModes.stream().anyMatch(gm -> gm.inWorld(world)); + } + + public String getGameModeName(World world) { + return gameModes.stream().filter(gm -> gm.inWorld(world)).findFirst() + .map(gm -> gm.getDescription().getName()).orElse(""); + } + + public String getGameModePermPrefix(World world) { + return gameModes.stream().filter(gm -> gm.inWorld(world)).findFirst() + .map(GameModeAddon::getPermissionPrefix).orElse(""); + } + + public boolean isCoveredGameMode(String gameMode) { + return gameModes.stream().anyMatch(gm -> gm.getDescription().getName().equals(gameMode)); + } + + public JoinListener getJoinListener() { + return joinListener; + } + + /* ========================================================================= + * Placeholders + * ========================================================================= */ + + private void registerPlaceholders(GameModeAddon gm) { + if (getPlugin().getPlaceholdersManager() == null) return; + Registry.MATERIAL.stream() + .filter(Material::isBlock) + .forEach(m -> registerCountAndLimitPlaceholders(m.getKey(), gm)); + Arrays.stream(EntityType.values()) + .forEach(e -> registerCountAndLimitPlaceholders(e, gm)); + } + + private static final List ENV_SUFFIXES = List.of("", "_overworld", "_nether", "_end"); + + /** + * Registers placeholders for the count and limit of the material. + * + *

Env-aware naming: + *

    + *
  • {@code Limits__island__count} — total across all envs (back-compat)
  • + *
  • {@code Limits__island__overworld_count} — overworld only
  • + *
  • {@code Limits__island__nether_count} — nether only
  • + *
  • {@code Limits__island__end_count} — end only
  • + *
+ * Same pattern for {@code _limit} and {@code _base_limit}. + */ + private void registerCountAndLimitPlaceholders(NamespacedKey m, GameModeAddon gm) { + for (String suffix : ENV_SUFFIXES) { + Environment env = envForSuffix(suffix); + String base = gm.getDescription().getName().toLowerCase(Locale.ROOT) + ISLAND_PLACEHOLDER + + m.toString().toLowerCase(Locale.ROOT); + getPlugin().getPlaceholdersManager().registerPlaceholder(this, + base + suffix + "_count", + user -> String.valueOf(getCount(user, m, gm, env))); + getPlugin().getPlaceholdersManager().registerPlaceholder(this, + base + suffix + "_limit", + user -> getLimit(user, m, gm, env)); + getPlugin().getPlaceholdersManager().registerPlaceholder(this, + base + suffix + "_base_limit", + user -> getBaseLimit(user, m, gm, env)); + } + } + + private void registerCountAndLimitPlaceholders(EntityType e, GameModeAddon gm) { + for (String suffix : ENV_SUFFIXES) { + Environment env = envForSuffix(suffix); + String base = gm.getDescription().getName().toLowerCase(Locale.ROOT) + ISLAND_PLACEHOLDER + + e.toString().toLowerCase(Locale.ROOT); + getPlugin().getPlaceholdersManager().registerPlaceholder(this, + base + suffix + "_count", + user -> String.valueOf(getCount(user, e, gm, env))); + getPlugin().getPlaceholdersManager().registerPlaceholder(this, + base + suffix + "_limit", + user -> getLimit(user, e, gm, env)); + getPlugin().getPlaceholdersManager().registerPlaceholder(this, + base + suffix + "_base_limit", + user -> getBaseLimit(user, e, gm, env)); + } + } + + /** {@code null} env means "sum/aggregate across all envs". */ + @Nullable + private static Environment envForSuffix(String suffix) { + return switch (suffix) { + case "_overworld" -> Environment.NORMAL; + case "_nether" -> Environment.NETHER; + case "_end" -> Environment.THE_END; + default -> null; + }; + } + + private int getCount(@Nullable User user, NamespacedKey m, GameModeAddon gm, @Nullable Environment env) { + Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); + if (is == null) return 0; + IslandBlockCount ibc = getBlockLimitListener().getIsland(is.getUniqueId()); + if (ibc == null) return 0; + return env == null ? ibc.getBlockCount(m) : ibc.getBlockCount(env, m); + } + + private long getCount(@Nullable User user, EntityType e, GameModeAddon gm, @Nullable Environment env) { + Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); + if (is == null) return 0; + IslandBlockCount ibc = getBlockLimitListener().getIsland(is.getUniqueId()); + if (ibc == null) return 0; + return env == null ? ibc.getEntityCount(e) : ibc.getEntityCount(env, e); + } + + private String getLimit(@Nullable User user, NamespacedKey m, GameModeAddon gm, @Nullable Environment env) { + Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); + if (is == null) return LIMIT_NOT_SET; + if (user != null) { + getJoinListener().checkPerms(user.getPlayer(), gm.getPermissionPrefix() + "island.limit.", + is.getUniqueId(), gm.getDescription().getName()); + } + World w = worldForEnv(gm, env); + int limit = getBlockLimitListener().getMaterialLimits(w, is.getUniqueId()).getOrDefault(m, -1); + return limit == -1 ? LIMIT_NOT_SET : String.valueOf(limit); + } + + private String getBaseLimit(@Nullable User user, NamespacedKey m, GameModeAddon gm, @Nullable Environment env) { + Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); + if (is == null) return LIMIT_NOT_SET; + World w = worldForEnv(gm, env); + int limit = getBlockLimitListener().getMaterialLimits(w, is.getUniqueId()).getOrDefault(m, -1); + if (limit > 0) { + IslandBlockCount ibc = getBlockLimitListener().getIsland(is); + int offset = ibc.getBlockLimitOffset(env == null ? Environment.NORMAL : env, m); + limit -= offset; + } + return limit == -1 ? LIMIT_NOT_SET : String.valueOf(limit); + } + + private String getLimit(@Nullable User user, EntityType e, GameModeAddon gm, @Nullable Environment env) { + Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); + if (is == null) return LIMIT_NOT_SET; + IslandBlockCount ibc = getBlockLimitListener().getIsland(is); + Environment effective = env == null ? Environment.NORMAL : env; + int limit = ibc.getEntityLimit(effective, e); + if (limit < 0) { + Map envLimits = getSettings().getLimits(effective); + if (envLimits.containsKey(e)) limit = envLimits.get(e); + } + return limit == -1 ? LIMIT_NOT_SET : String.valueOf(limit); + } + + private String getBaseLimit(@Nullable User user, EntityType e, GameModeAddon gm, @Nullable Environment env) { + Island is = gm.getIslands().getIsland(gm.getOverWorld(), user); + Environment effective = env == null ? Environment.NORMAL : env; + Map envLimits = getSettings().getLimits(effective); + if (is == null || !envLimits.containsKey(e)) return LIMIT_NOT_SET; + int limit = envLimits.get(e); + return limit == -1 ? LIMIT_NOT_SET : String.valueOf(limit); + } + + private World worldForEnv(GameModeAddon gm, @Nullable Environment env) { + if (env == null) return gm.getOverWorld(); + return switch (env) { + case NETHER -> gm.getNetherWorld() != null ? gm.getNetherWorld() : gm.getOverWorld(); + case THE_END -> gm.getEndWorld() != null ? gm.getEndWorld() : gm.getOverWorld(); + default -> gm.getOverWorld(); + }; + } +} diff --git a/src/main/java/world/bentobox/limits/Settings.java b/src/main/java/world/bentobox/limits/Settings.java index 1d5251f..7c6cb79 100644 --- a/src/main/java/world/bentobox/limits/Settings.java +++ b/src/main/java/world/bentobox/limits/Settings.java @@ -1,184 +1,273 @@ -package world.bentobox.limits; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.EnumMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Collectors; - -import org.bukkit.Material; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.entity.EntityType; - -public class Settings { - - enum GeneralGroup { - ANIMALS, MOBS - } - - private final Map general = new EnumMap<>(GeneralGroup.class); - private final Map limits = new EnumMap<>(EntityType.class); - private final Map> groupLimits = new EnumMap<>(EntityType.class); - private final List gameModes; - private final boolean logLimitsOnJoin; - private final boolean asyncGolums; - private static final List DISALLOWED = Arrays.asList( - EntityType.TNT, - EntityType.EVOKER_FANGS, - EntityType.LLAMA_SPIT, - EntityType.DRAGON_FIREBALL, - EntityType.AREA_EFFECT_CLOUD, - EntityType.END_CRYSTAL, - EntityType.SMALL_FIREBALL, - EntityType.FIREBALL, - EntityType.EXPERIENCE_BOTTLE, - EntityType.EXPERIENCE_ORB, - EntityType.SHULKER_BULLET, - EntityType.WITHER_SKULL, - EntityType.TRIDENT, - EntityType.ARROW, - EntityType.SPECTRAL_ARROW, - EntityType.SNOWBALL, - EntityType.EGG, - EntityType.LEASH_KNOT, - EntityType.GIANT, - EntityType.ENDER_PEARL, - EntityType.ENDER_DRAGON, - EntityType.ITEM_FRAME, - EntityType.PAINTING); - - public Settings(Limits addon) { - - // GameModes - gameModes = addon.getConfig().getStringList("gamemodes"); - - ConfigurationSection el = addon.getConfig().getConfigurationSection("entitylimits"); - if (el != null) { - for (String key : el.getKeys(false)) { - if (key.equalsIgnoreCase("ANIMALS")) { - general.put(GeneralGroup.ANIMALS, el.getInt(key, 0)); - } else if (key.equalsIgnoreCase("MOBS")) { - general.put(GeneralGroup.MOBS, el.getInt(key, 0)); - } else { - EntityType type = getType(key); - if (type != null) { - if (DISALLOWED.contains(type)) { - addon.logError("Entity type: " + key + " is not supported - skipping..."); - } else { - limits.put(type, el.getInt(key, 0)); - } - } else { - addon.logError("Unknown entity type: " + key + " - skipping..."); - } - } - } - } - // Log limits on join - logLimitsOnJoin = addon.getConfig().getBoolean("log-limits-on-join", true); - // Async Golums - asyncGolums = addon.getConfig().getBoolean("async-golums", true); - - addon.log("Entity limits:"); - limits.entrySet().stream().map(e -> "Limit " + e.getKey().toString() + " to " + e.getValue()).forEach(addon::log); - - //group limits - el = addon.getConfig().getConfigurationSection("entitygrouplimits"); - if (el != null) { - for (String name : el.getKeys(false)) { - int limit = el.getInt(name + ".limit"); - String iconName = el.getString(name + ".icon", "BARRIER"); - Material icon = Material.BARRIER; - try { - icon = Material.valueOf(iconName.toUpperCase(Locale.ENGLISH)); - } catch (Exception e) { - addon.logError("Invalid group icon name: " + iconName + ". Use a Bukkit Material."); - icon = Material.BARRIER; - } - Set entities = el.getStringList(name + ".entities").stream().map(s -> { - EntityType type = getType(s); - if (type != null) { - if (DISALLOWED.contains(type)) { - addon.logError("Entity type: " + s + " is not supported - skipping..."); - } else { - return type; - } - } else { - addon.logError("Unknown entity type: " + s + " - skipping..."); - } - return null; - }).filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); - if (entities.isEmpty()) - continue; - EntityGroup group = new EntityGroup(name, entities, limit, icon); - entities.forEach(e -> { - List groups = groupLimits.getOrDefault(e, new ArrayList<>()); - groups.add(group); - groupLimits.put(e, groups); - }); - } - } - - addon.log("Entity group limits:"); - getGroupLimitDefinitions().stream().map(e -> "Limit " + e.getName() + " (" + e.getTypes().stream().map(Enum::name).collect(Collectors.joining(", ")) + ") to " + e.getLimit()).forEach(addon::log); - } - - private EntityType getType(String key) { - return Arrays.stream(EntityType.values()).filter(v -> v.name().equalsIgnoreCase(key)).findFirst().orElse(null); - } - - /** - * @return the entity limits - */ - public Map getLimits() { - return Collections.unmodifiableMap(limits); - } - - /** - * @return the group limits - */ - public Map> getGroupLimits() { - return groupLimits; - } - - /** - * @return the group limit definitions - */ - public List getGroupLimitDefinitions() { - return groupLimits.values().stream().flatMap(Collection::stream).distinct().toList(); - } - - /** - * @return the gameModes - */ - public List getGameModes() { - return gameModes; - } - - /** - * @return the logLimitsOnJoin - */ - public boolean isLogLimitsOnJoin() { - return logLimitsOnJoin; - } - - /** - * @return the asyncGolums - */ - public boolean isAsyncGolums() { - return asyncGolums; - } - - /** - * @return the general coverage map - */ - public Map getGeneral() { - return general; - } -} +package world.bentobox.limits; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import org.bukkit.Material; +import org.bukkit.World.Environment; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.EntityType; + +public class Settings { + + enum GeneralGroup { + ANIMALS, MOBS + } + + /** + * Standard environments with separated limits. {@link Environment#CUSTOM} is intentionally excluded — + * limits-aware game modes target the three vanilla environments. + */ + public static final List ENVIRONMENTS = List.of(Environment.NORMAL, Environment.NETHER, + Environment.THE_END); + + private static final Map ENV_SUFFIX = Map.of( + Environment.NORMAL, "", + Environment.NETHER, "-nether", + Environment.THE_END, "-end"); + + private static final String SKIPPING = " - skipping..."; + private static final String LIMIT_SUFFIX = ".limit"; + + private final Map general = new EnumMap<>(GeneralGroup.class); + /** Per-env entity type limits (env defaults from config). */ + private final Map> envLimits = new EnumMap<>(Environment.class); + /** Group definitions and which groups each entity type belongs to. */ + private final Map> groupLimits = new EnumMap<>(EntityType.class); + /** Per-env group-limit overrides (env defaults from config). */ + private final Map> envGroupLimits = new EnumMap<>(Environment.class); + private final List gameModes; + private final boolean logLimitsOnJoin; + private final boolean asyncGolums; + private static final List DISALLOWED = Arrays.asList( + EntityType.TNT, + EntityType.EVOKER_FANGS, + EntityType.LLAMA_SPIT, + EntityType.DRAGON_FIREBALL, + EntityType.AREA_EFFECT_CLOUD, + EntityType.END_CRYSTAL, + EntityType.SMALL_FIREBALL, + EntityType.FIREBALL, + EntityType.EXPERIENCE_BOTTLE, + EntityType.EXPERIENCE_ORB, + EntityType.SHULKER_BULLET, + EntityType.WITHER_SKULL, + EntityType.TRIDENT, + EntityType.ARROW, + EntityType.SPECTRAL_ARROW, + EntityType.SNOWBALL, + EntityType.EGG, + EntityType.LEASH_KNOT, + EntityType.GIANT, + EntityType.ENDER_PEARL, + EntityType.ENDER_DRAGON, + EntityType.ITEM_FRAME, + EntityType.PAINTING); + + public Settings(Limits addon) { + + // GameModes + gameModes = addon.getConfig().getStringList("gamemodes"); + + // Initialise empty env maps + for (Environment env : ENVIRONMENTS) { + envLimits.put(env, new EnumMap<>(EntityType.class)); + envGroupLimits.put(env, new java.util.HashMap<>()); + } + + // Pass 1: parse the unsuffixed entitylimits as the default for every env + loadEntityLimits(addon, "entitylimits", ENVIRONMENTS); + // Pass 2: env-suffixed overrides + loadEntityLimits(addon, "entitylimits-nether", List.of(Environment.NETHER)); + loadEntityLimits(addon, "entitylimits-end", List.of(Environment.THE_END)); + + // Log limits on join + logLimitsOnJoin = addon.getConfig().getBoolean("log-limits-on-join", true); + // Async Golums + asyncGolums = addon.getConfig().getBoolean("async-golums", true); + + addon.log("Entity limits:"); + envLimits.forEach((env, m) -> m.entrySet().stream() + .map(e -> "Limit " + e.getKey() + " in " + env + " to " + e.getValue()) + .forEach(addon::log)); + + // Group definitions live in the unsuffixed entitygrouplimits section. The entire + // group's limit is the "default for all envs"; env-suffixed sections override that + // limit per-group-name (icon and member set are not env-overridable). + loadGroupDefinitions(addon); + loadGroupLimitOverrides(addon, "entitygrouplimits-nether", Environment.NETHER); + loadGroupLimitOverrides(addon, "entitygrouplimits-end", Environment.THE_END); + + addon.log("Entity group limits:"); + getGroupLimitDefinitions().stream() + .map(e -> "Limit " + e.getName() + " (" + + e.getTypes().stream().map(Enum::name).collect(Collectors.joining(", ")) + ") to " + + e.getLimit()) + .forEach(addon::log); + } + + private void loadEntityLimits(Limits addon, String section, List targetEnvs) { + ConfigurationSection el = addon.getConfig().getConfigurationSection(section); + if (el == null) return; + for (String key : el.getKeys(false)) { + if (key.equalsIgnoreCase("ANIMALS")) { + if (targetEnvs.contains(Environment.NORMAL)) { + general.put(GeneralGroup.ANIMALS, el.getInt(key, 0)); + } + } else if (key.equalsIgnoreCase("MOBS")) { + if (targetEnvs.contains(Environment.NORMAL)) { + general.put(GeneralGroup.MOBS, el.getInt(key, 0)); + } + } else { + applyEntityKey(addon, section, el, key, targetEnvs); + } + } + } + + private void applyEntityKey(Limits addon, String section, ConfigurationSection el, String key, + List targetEnvs) { + EntityType type = getType(key); + if (type == null) { + addon.logError("Unknown entity type in " + section + ": " + key + SKIPPING); + return; + } + if (DISALLOWED.contains(type)) { + addon.logError("Entity type in " + section + " not supported: " + key + SKIPPING); + return; + } + int value = el.getInt(key, 0); + targetEnvs.forEach(env -> envLimits.get(env).put(type, value)); + } + + private void loadGroupDefinitions(Limits addon) { + ConfigurationSection el = addon.getConfig().getConfigurationSection("entitygrouplimits"); + if (el == null) return; + for (String name : el.getKeys(false)) { + int limit = el.getInt(name + LIMIT_SUFFIX); + String iconName = el.getString(name + ".icon", "BARRIER"); + Material icon; + try { + icon = Material.valueOf(iconName.toUpperCase(Locale.ENGLISH)); + } catch (Exception e) { + addon.logError("Invalid group icon name: " + iconName + ". Use a Bukkit Material."); + icon = Material.BARRIER; + } + Set entities = el.getStringList(name + ".entities").stream().map(s -> { + EntityType type = getType(s); + if (type == null) { + addon.logError("Unknown entity type: " + s + SKIPPING); + return null; + } + if (DISALLOWED.contains(type)) { + addon.logError("Entity type: " + s + " is not supported" + SKIPPING); + return null; + } + return type; + }).filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + if (entities.isEmpty()) continue; + EntityGroup group = new EntityGroup(name, entities, limit, icon); + entities.forEach(e -> groupLimits.computeIfAbsent(e, k -> new ArrayList<>()).add(group)); + // Default group limit applies to every env unless overridden. + ENVIRONMENTS.forEach(env -> envGroupLimits.get(env).put(name, limit)); + } + } + + private void loadGroupLimitOverrides(Limits addon, String section, Environment env) { + ConfigurationSection el = addon.getConfig().getConfigurationSection(section); + if (el == null) return; + for (String name : el.getKeys(false)) { + applyGroupOverride(addon, el, section, name, env); + } + } + + private void applyGroupOverride(Limits addon, ConfigurationSection el, String section, String name, + Environment env) { + // Accept either a flat int (Monsters: 100) or a nested .limit (Monsters.limit: 100) + Integer limit = resolveGroupLimit(el, name); + if (limit == null) { + addon.logError("Group override " + section + "." + name + " missing limit - skipping."); + return; + } + // Group must already be defined in the base entitygrouplimits section + boolean exists = getGroupLimitDefinitions().stream().anyMatch(g -> g.getName().equals(name)); + if (!exists) { + addon.logError("Group override " + section + "." + name + + " refers to an undefined group - define it under entitygrouplimits first."); + return; + } + envGroupLimits.get(env).put(name, limit); + } + + private static Integer resolveGroupLimit(ConfigurationSection el, String name) { + if (el.isInt(name)) return el.getInt(name); + if (el.isInt(name + LIMIT_SUFFIX)) return el.getInt(name + LIMIT_SUFFIX); + return null; + } + + private EntityType getType(String key) { + return Arrays.stream(EntityType.values()).filter(v -> v.name().equalsIgnoreCase(key)).findFirst().orElse(null); + } + + /** + * Return the env-specific entity limit map. Mutate via Limits API only. + */ + public Map getLimits(Environment env) { + return Collections.unmodifiableMap(envLimits.getOrDefault(env, Collections.emptyMap())); + } + + /** + * Group-name → limit overrides for this environment. + */ + public Map getGroupLimits(Environment env) { + return Collections.unmodifiableMap(envGroupLimits.getOrDefault(env, Collections.emptyMap())); + } + + /** + * @return the entity-type → group-list lookup + */ + public Map> getGroupLimits() { + return groupLimits; + } + + /** + * @return the group definitions + */ + public List getGroupLimitDefinitions() { + return groupLimits.values().stream().flatMap(Collection::stream).distinct().toList(); + } + + public List getGameModes() { + return gameModes; + } + + public boolean isLogLimitsOnJoin() { + return logLimitsOnJoin; + } + + public boolean isAsyncGolums() { + return asyncGolums; + } + + public Map getGeneral() { + return general; + } + + /** + * Config-file suffix used for environment-specific sections. + * @param env environment + * @return suffix like "-nether", "-end", or "" for overworld + */ + public static String suffixFor(Environment env) { + return ENV_SUFFIX.getOrDefault(env, ""); + } +} diff --git a/src/main/java/world/bentobox/limits/calculators/RecountCalculator.java b/src/main/java/world/bentobox/limits/calculators/RecountCalculator.java index 64cf4b3..fd14ae0 100644 --- a/src/main/java/world/bentobox/limits/calculators/RecountCalculator.java +++ b/src/main/java/world/bentobox/limits/calculators/RecountCalculator.java @@ -1,361 +1,275 @@ -package world.bentobox.limits.calculators; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.EnumMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Queue; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.function.BooleanSupplier; -import java.util.function.LongSupplier; - -import org.bukkit.Bukkit; -import org.bukkit.Chunk; -import org.bukkit.ChunkSnapshot; -import org.bukkit.Location; -import org.bukkit.NamespacedKey; -import org.bukkit.Tag; -import org.bukkit.World; -import org.bukkit.World.Environment; -import org.bukkit.block.data.BlockData; -import org.bukkit.block.data.type.Slab; -import org.bukkit.scheduler.BukkitTask; - -import world.bentobox.bentobox.BentoBox; -import world.bentobox.bentobox.database.objects.Island; -import world.bentobox.bentobox.util.Pair; -import world.bentobox.bentobox.util.Util; -import world.bentobox.limits.Limits; -import world.bentobox.limits.calculators.Results.Result; -import world.bentobox.limits.listeners.BlockLimitsListener; -import world.bentobox.limits.objects.IslandBlockCount; - -/** - * Counter for limits - * @author tastybento - * - */ -public class RecountCalculator { - public static final long MAX_AMOUNT = 10000; - private static final int CHUNKS_TO_SCAN = 100; - private static final int CALCULATION_TIMEOUT = 5; // Minutes - - - private final Limits addon; - private final Queue> chunksToCheck; - private final Island island; - private final CompletableFuture r; - - - private final Results results; - private final Map worlds = new EnumMap<>(Environment.class); - private final List stackedBlocks = new ArrayList<>(); - private BukkitTask finishTask; - private final BlockLimitsListener bll; - private final World world; - private IslandBlockCount ibc; - - - /** - * Constructor to get the level for an island - * @param addon - addon - * @param island - the island to scan - * @param r - completable result that will be completed when the calculation is complete - */ - public RecountCalculator(Limits addon, Island island, CompletableFuture r) { - this.addon = addon; - this.bll = addon.getBlockLimitListener(); - this.island = island; - this.ibc = bll.getIsland(Objects.requireNonNull(island).getUniqueId()); - this.r = r; - results = new Results(); - chunksToCheck = getChunksToScan(island); - // Set up the worlds - this.world = Objects.requireNonNull(Util.getWorld(island.getWorld())); - worlds.put(Environment.NORMAL, world); - boolean isNether = addon.getPlugin().getIWM().isNetherGenerate(world) && addon.getPlugin().getIWM().isNetherIslands(world); - boolean isEnd = addon.getPlugin().getIWM().isEndGenerate(world) && addon.getPlugin().getIWM().isEndIslands(world); - - // Nether - if (isNether) { - World nether = addon.getPlugin().getIWM().getNetherWorld(island.getWorld()); - if (nether != null) { - worlds.put(Environment.NETHER, nether); - } - } - // End - if (isEnd) { - World end = addon.getPlugin().getIWM().getEndWorld(island.getWorld()); - if (end != null) { - worlds.put(Environment.THE_END, end); - } - } - } - - private void checkBlock(BlockData b) { - NamespacedKey md = bll.fixMaterial(b); - // md is limited - if (bll.getMaterialLimits(world, island.getUniqueId()).containsKey(md)) { - results.mdCount.add(md); - } - } - /** - * Get a set of all the chunks in island - * @param island - island - * @return - set of pairs of x,z coordinates to check - */ - private Queue> getChunksToScan(Island island) { - Queue> chunkQueue = new ConcurrentLinkedQueue<>(); - for (int x = island.getMinProtectedX(); x < (island.getMinProtectedX() + island.getProtectionRange() * 2 + 16); x += 16) { - for (int z = island.getMinProtectedZ(); z < (island.getMinProtectedZ() + island.getProtectionRange() * 2 + 16); z += 16) { - chunkQueue.add(new Pair<>(x >> 4, z >> 4)); - } - } - return chunkQueue; - } - - - /** - * @return the island - */ - public Island getIsland() { - return island; - } - - /** - * Get the completable result for this calculation - * @return the r - */ - public CompletableFuture getR() { - return r; - } - - /** - * @return the results - */ - public Results getResults() { - return results; - } - - /** - * Get a chunk async - * @param env - the environment - * @param x - chunk x coordinate - * @param z - chunk z coordinate - * @return a future chunk or future null if there is no chunk to load, e.g., there is no island nether - */ - private CompletableFuture> getWorldChunk(Environment env, Queue> pairList) { - if (worlds.containsKey(env)) { - CompletableFuture> r2 = new CompletableFuture<>(); - List chunkList = new ArrayList<>(); - World world = worlds.get(env); - // Get the chunk, and then coincidentally check the RoseStacker - loadChunks(r2, world, pairList, chunkList); - return r2; - } - return CompletableFuture.completedFuture(Collections.emptyList()); - } - - private void loadChunks(CompletableFuture> r2, World world, Queue> pairList, - List chunkList) { - if (pairList.isEmpty()) { - r2.complete(chunkList); - return; - } - Pair p = pairList.poll(); - Util.getChunkAtAsync(world, p.x, p.z, world.getEnvironment().equals(Environment.NETHER)).thenAccept(chunk -> { - if (chunk != null) { - chunkList.add(chunk); - // roseStackerCheck(chunk); - } - loadChunks(r2, world, pairList, chunkList); // Iteration - }); - } - /* - private void roseStackerCheck(Chunk chunk) { - if (addon.isRoseStackersEnabled()) { - RoseStackerAPI.getInstance().getStackedBlocks(Collections.singletonList(chunk)).forEach(e -> { - // Blocks below sea level can be scored differently - boolean belowSeaLevel = seaHeight > 0 && e.getLocation().getY() <= seaHeight; - // Check block once because the base block will be counted in the chunk snapshot - for (int _x = 0; _x < e.getStackSize() - 1; _x++) { - checkBlock(e.getBlock().getType(), belowSeaLevel); - } - }); - } - } - */ - /** - * Count the blocks on the island - * @param chunk chunk to scan - */ - private void scanAsync(Chunk chunk) { - ChunkSnapshot chunkSnapshot = chunk.getChunkSnapshot(); - for (int x = 0; x< 16; x++) { - // Check if the block coordinate is inside the protection zone and if not, don't count it - if (chunkSnapshot.getX() * 16 + x < island.getMinProtectedX() || chunkSnapshot.getX() * 16 + x >= island.getMinProtectedX() + island.getProtectionRange() * 2) { - continue; - } - for (int z = 0; z < 16; z++) { - // Check if the block coordinate is inside the protection zone and if not, don't count it - if (chunkSnapshot.getZ() * 16 + z < island.getMinProtectedZ() || chunkSnapshot.getZ() * 16 + z >= island.getMinProtectedZ() + island.getProtectionRange() * 2) { - continue; - } - // Only count to the highest block in the world for some optimization - for (int y = chunk.getWorld().getMinHeight(); y < chunk.getWorld().getMaxHeight(); y++) { - BlockData blockData = chunkSnapshot.getBlockData(x, y, z); - // Slabs can be doubled, so check them twice - if (Tag.SLABS.isTagged(blockData.getMaterial())) { - Slab slab = (Slab)blockData; - if (slab.getType().equals(Slab.Type.DOUBLE)) { - checkBlock(blockData); - } - } - // Hook for Wild Stackers (Blocks Only) - this has to use the real chunk - /* - if (addon.isStackersEnabled() && blockData.getMaterial() == Material.CAULDRON) { - stackedBlocks.add(new Location(chunk.getWorld(), x + chunkSnapshot.getX() * 16,y,z + chunkSnapshot.getZ() * 16)); - } - */ - // Add the value of the block's material - checkBlock(blockData); - } - } - } - } - - /** - * Scan the chunk chests and count the blocks - * @param chunks - the chunk to scan - * @return future that completes when the scan is done and supplies a boolean that will be true if the scan was successful, false if not - */ - private CompletableFuture scanChunk(List chunks) { - // If the chunk hasn't been generated, return - if (chunks == null || chunks.isEmpty()) { - return CompletableFuture.completedFuture(false); - } - // Count blocks in chunk - CompletableFuture result = new CompletableFuture<>(); - - Bukkit.getScheduler().runTaskAsynchronously(BentoBox.getInstance(), () -> { - chunks.forEach(chunk -> scanAsync(chunk)); - Bukkit.getScheduler().runTask(addon.getPlugin(),() -> result.complete(true)); - }); - return result; - } - - /** - * Scan the next chunk on the island - * @return completable boolean future that will be true if more chunks are left to be scanned, and false if not - */ - public CompletableFuture scanNextChunk() { - if (chunksToCheck.isEmpty()) { - addon.logError("Unexpected: no chunks to scan!"); - // This should not be needed, but just in case - return CompletableFuture.completedFuture(false); - } - // Retrieve and remove from the queue - Queue> pairList = new ConcurrentLinkedQueue<>(); - int i = 0; - while (!chunksToCheck.isEmpty() && i++ < CHUNKS_TO_SCAN) { - pairList.add(chunksToCheck.poll()); - } - Queue> endPairList = new ConcurrentLinkedQueue<>(pairList); - Queue> netherPairList = new ConcurrentLinkedQueue<>(pairList); - // Set up the result - CompletableFuture result = new CompletableFuture<>(); - // Get chunks and scan - // Get chunks and scan - getWorldChunk(Environment.THE_END, endPairList).thenAccept(endChunks -> - scanChunk(endChunks).thenAccept(b -> - getWorldChunk(Environment.NETHER, netherPairList).thenAccept(netherChunks -> - scanChunk(netherChunks).thenAccept(b2 -> - getWorldChunk(Environment.NORMAL, pairList).thenAccept(normalChunks -> - scanChunk(normalChunks).thenAccept(b3 -> - // Complete the result now that all chunks have been scanned - result.complete(!chunksToCheck.isEmpty())))) - ) - ) - ); - - return result; - } - - /** - * Finalizes the calculations and makes the report - */ - public void tidyUp() { - // Finalize calculations - if (ibc == null) { - ibc = new IslandBlockCount(island.getUniqueId(), addon.getPlugin().getIWM().getAddon(world).map(a -> a.getDescription().getName()).orElse("default")); - } - ibc.getBlockCounts().clear(); - results.getMdCount().forEach(ibc::add); - bll.setIsland(island.getUniqueId(), ibc); - //Bukkit.getScheduler().runTask(addon.getPlugin(), () -> sender.sendMessage("admin.limits.calc.finished")); - - // All done. - } - - public void scanIsland(LongSupplier startTime, Runnable onRemove, BooleanSupplier isCancelled, Runnable recurse) { - // Scan the next chunk - scanNextChunk().thenAccept(r -> { - if (!Bukkit.isPrimaryThread()) { - addon.getPlugin().logError("scanChunk not on Primary Thread!"); - } - // Timeout check - if (System.currentTimeMillis() - startTime.getAsLong() > CALCULATION_TIMEOUT * 60000) { - // Done - onRemove.run(); - getR().complete(new Results(Result.TIMEOUT)); - addon.logError("Level calculation timed out after " + CALCULATION_TIMEOUT + "m for island: " + getIsland()); - return; - } - if (Boolean.TRUE.equals(r) && !isCancelled.getAsBoolean()) { - // scanNextChunk returns true if there are more chunks to scan - recurse.run(); - } else { - // Done - onRemove.run(); - // Chunk finished - // This was the last chunk - handleStackedBlocks(); - long checkTime = System.currentTimeMillis(); - finishTask = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), () -> { - // Check every half second if all the chests and stacks have been cleared - if ((stackedBlocks.isEmpty()) || System.currentTimeMillis() - checkTime > MAX_AMOUNT) { - this.tidyUp(); - this.getR().complete(getResults()); - finishTask.cancel(); - } - }, 0, 10L); - - } - }); - } - - private void handleStackedBlocks() { - // Deal with any stacked blocks - /* - Iterator it = stackedBlocks.iterator(); - while (it.hasNext()) { - Location v = it.next(); - Util.getChunkAtAsync(v).thenAccept(c -> { - Block cauldronBlock = v.getBlock(); - boolean belowSeaLevel = seaHeight > 0 && v.getBlockY() <= seaHeight; - if (WildStackerAPI.getWildStacker().getSystemManager().isStackedBarrel(cauldronBlock)) { - StackedBarrel barrel = WildStackerAPI.getStackedBarrel(cauldronBlock); - int barrelAmt = WildStackerAPI.getBarrelAmount(cauldronBlock); - for (int _x = 0; _x < barrelAmt; _x++) { - checkBlock(barrel.getType(), belowSeaLevel); - } - } - it.remove(); - }); - } - */ - } -} +package world.bentobox.limits.calculators; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.function.BooleanSupplier; +import java.util.function.LongSupplier; + +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.ChunkSnapshot; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.Tag; +import org.bukkit.World; +import org.bukkit.World.Environment; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.Slab; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.scheduler.BukkitTask; + +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.util.Pair; +import world.bentobox.bentobox.util.Util; +import world.bentobox.limits.Limits; +import world.bentobox.limits.calculators.Results.Result; +import world.bentobox.limits.listeners.BlockLimitsListener; +import world.bentobox.limits.objects.IslandBlockCount; + +/** + * Counter for limits. + * + *

Scans every loaded chunk in each of the island's environment worlds (overworld, + * nether, end) and rebuilds {@link IslandBlockCount}'s per-env block and entity + * counts from scratch. + * + * @author tastybento + */ +public class RecountCalculator { + public static final long MAX_AMOUNT = 10000; + private static final int CHUNKS_TO_SCAN = 100; + private static final int CALCULATION_TIMEOUT = 5; + + private final Limits addon; + private final Queue> chunksToCheck; + private final Island island; + private final CompletableFuture r; + private final Results results; + private final Map worlds = new EnumMap<>(Environment.class); + private final List stackedBlocks = new ArrayList<>(); + private BukkitTask finishTask; + private final BlockLimitsListener bll; + private final World world; + private IslandBlockCount ibc; + + public RecountCalculator(Limits addon, Island island, CompletableFuture r) { + this.addon = addon; + this.bll = addon.getBlockLimitListener(); + this.island = island; + this.ibc = bll.getIsland(Objects.requireNonNull(island)); + this.r = r; + results = new Results(); + chunksToCheck = getChunksToScan(island); + this.world = Objects.requireNonNull(Util.getWorld(island.getWorld())); + worlds.put(Environment.NORMAL, world); + boolean isNether = addon.getPlugin().getIWM().isNetherGenerate(world) + && addon.getPlugin().getIWM().isNetherIslands(world); + boolean isEnd = addon.getPlugin().getIWM().isEndGenerate(world) + && addon.getPlugin().getIWM().isEndIslands(world); + if (isNether) { + World nether = addon.getPlugin().getIWM().getNetherWorld(island.getWorld()); + if (nether != null) worlds.put(Environment.NETHER, nether); + } + if (isEnd) { + World end = addon.getPlugin().getIWM().getEndWorld(island.getWorld()); + if (end != null) worlds.put(Environment.THE_END, end); + } + } + + private void checkBlock(Environment env, BlockData b) { + NamespacedKey md = bll.fixMaterial(b); + // Only count materials that are tracked at any level (env default, world override, island). + if (bll.getMaterialLimits(worlds.get(env), island.getUniqueId()).containsKey(md)) { + results.getBlockCount(env).add(md); + } + } + + private Queue> getChunksToScan(Island island) { + Queue> chunkQueue = new ConcurrentLinkedQueue<>(); + for (int x = island.getMinProtectedX(); x < (island.getMinProtectedX() + island.getProtectionRange() * 2 + + 16); x += 16) { + for (int z = island.getMinProtectedZ(); z < (island.getMinProtectedZ() + island.getProtectionRange() * 2 + + 16); z += 16) { + chunkQueue.add(new Pair<>(x >> 4, z >> 4)); + } + } + return chunkQueue; + } + + public Island getIsland() { + return island; + } + + public CompletableFuture getR() { + return r; + } + + public Results getResults() { + return results; + } + + private CompletableFuture> getWorldChunk(Environment env, Queue> pairList) { + if (worlds.containsKey(env)) { + World envWorld = worlds.get(env); + boolean isNether = envWorld.getEnvironment().equals(Environment.NETHER); + List> futures = new ArrayList<>(); + while (!pairList.isEmpty()) { + Pair p = pairList.poll(); + futures.add(Util.getChunkAtAsync(envWorld, p.x, p.z, isNether)); + } + if (futures.isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList()); + } + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + private void scanAsync(Environment env, Chunk chunk) { + ChunkSnapshot chunkSnapshot = chunk.getChunkSnapshot(); + int minX = island.getMinProtectedX(); + int maxX = minX + island.getProtectionRange() * 2; + int minZ = island.getMinProtectedZ(); + int maxZ = minZ + island.getProtectionRange() * 2; + int chunkBaseX = chunkSnapshot.getX() * 16; + int chunkBaseZ = chunkSnapshot.getZ() * 16; + int minY = chunk.getWorld().getMinHeight(); + int maxY = chunk.getWorld().getMaxHeight(); + for (int x = 0; x < 16; x++) { + int absX = chunkBaseX + x; + if (absX < minX || absX >= maxX) continue; + for (int z = 0; z < 16; z++) { + int absZ = chunkBaseZ + z; + if (absZ < minZ || absZ >= maxZ) continue; + scanColumn(env, chunkSnapshot, x, z, minY, maxY); + } + } + } + + private void scanColumn(Environment env, ChunkSnapshot chunkSnapshot, int x, int z, int minY, int maxY) { + for (int y = minY; y < maxY; y++) { + BlockData blockData = chunkSnapshot.getBlockData(x, y, z); + if (Tag.SLABS.isTagged(blockData.getMaterial()) + && ((Slab) blockData).getType().equals(Slab.Type.DOUBLE)) { + checkBlock(env, blockData); + } + checkBlock(env, blockData); + } + } + + private CompletableFuture scanChunk(Environment env, List chunks) { + if (chunks == null || chunks.isEmpty()) { + return CompletableFuture.completedFuture(false); + } + CompletableFuture result = new CompletableFuture<>(); + Bukkit.getScheduler().runTaskAsynchronously(BentoBox.getInstance(), () -> { + chunks.forEach(chunk -> scanAsync(env, chunk)); + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> result.complete(true)); + }); + return result; + } + + public CompletableFuture scanNextChunk() { + if (chunksToCheck.isEmpty()) { + addon.logError("Unexpected: no chunks to scan!"); + return CompletableFuture.completedFuture(false); + } + Queue> pairList = new ConcurrentLinkedQueue<>(); + int i = 0; + while (!chunksToCheck.isEmpty() && i++ < CHUNKS_TO_SCAN) { + pairList.add(chunksToCheck.poll()); + } + Queue> endPairList = new ConcurrentLinkedQueue<>(pairList); + Queue> netherPairList = new ConcurrentLinkedQueue<>(pairList); + + CompletableFuture> endFuture = getWorldChunk(Environment.THE_END, endPairList); + CompletableFuture> netherFuture = getWorldChunk(Environment.NETHER, netherPairList); + CompletableFuture> normalFuture = getWorldChunk(Environment.NORMAL, pairList); + + return CompletableFuture.allOf(endFuture, netherFuture, normalFuture) + .thenCompose(v -> scanChunk(Environment.THE_END, endFuture.join()) + .thenCompose(b -> scanChunk(Environment.NETHER, netherFuture.join())) + .thenCompose(b2 -> scanChunk(Environment.NORMAL, normalFuture.join())) + .thenApply(b3 -> !chunksToCheck.isEmpty())); + } + + private void scanEntities() { + for (Map.Entry e : worlds.entrySet()) { + Environment env = e.getKey(); + World w = e.getValue(); + for (Entity entity : w.getEntities()) { + if (!island.inIslandSpace(entity.getLocation())) continue; + if (entity instanceof LivingEntity || entity.getType().name().endsWith("_MINECART") + || entity.getType().name().equals("ARMOR_STAND") + || entity.getType().name().equals("ITEM_FRAME") + || entity.getType().name().equals("PAINTING")) { + results.getEntityCount(env).add(entity.getType()); + } + } + } + } + + public void tidyUp() { + ibc = bll.getIsland(island); + // Reset and write per-env block counts + ibc.clearAllBlockCounts(); + results.getEnvBlockCount().forEach((env, multiset) -> multiset.forEach(key -> ibc.add(env, key))); + + // Recount entities (loaded only) and write per-env + scanEntities(); + ibc.clearAllEntityCounts(); + results.getEnvEntityCount().forEach((env, multiset) -> multiset + .entrySet().forEach(en -> { + for (int i = 0; i < en.getCount(); i++) { + ibc.incrementEntity(env, en.getElement()); + } + })); + bll.setIsland(island.getUniqueId(), ibc); + } + + public void scanIsland(LongSupplier startTime, Runnable onRemove, BooleanSupplier isCancelled, Runnable recurse) { + scanNextChunk().thenAccept(hasMoreChunks -> { + if (!Bukkit.isPrimaryThread()) { + addon.getPlugin().logError("scanChunk not on Primary Thread!"); + } + if (System.currentTimeMillis() - startTime.getAsLong() > CALCULATION_TIMEOUT * 60000) { + onRemove.run(); + getR().complete(new Results(Result.TIMEOUT)); + addon.logError("Level calculation timed out after " + CALCULATION_TIMEOUT + "m for island: " + + getIsland()); + return; + } + if (Boolean.TRUE.equals(hasMoreChunks) && !isCancelled.getAsBoolean()) { + recurse.run(); + } else { + onRemove.run(); + handleStackedBlocks(); + long checkTime = System.currentTimeMillis(); + finishTask = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), () -> { + if ((stackedBlocks.isEmpty()) || System.currentTimeMillis() - checkTime > MAX_AMOUNT) { + this.tidyUp(); + this.getR().complete(getResults()); + finishTask.cancel(); + } + }, 0, 10L); + } + }); + } + + private void handleStackedBlocks() { + // Stacked-block plugin support is currently disabled; placeholder for future re-enablement. + } +} diff --git a/src/main/java/world/bentobox/limits/calculators/Results.java b/src/main/java/world/bentobox/limits/calculators/Results.java index daf50fc..a5aad31 100644 --- a/src/main/java/world/bentobox/limits/calculators/Results.java +++ b/src/main/java/world/bentobox/limits/calculators/Results.java @@ -1,57 +1,54 @@ -package world.bentobox.limits.calculators; - -import org.bukkit.NamespacedKey; -import org.bukkit.entity.EntityType; - -import com.google.common.collect.HashMultiset; -import com.google.common.collect.Multiset; - -public class Results { - public enum Result { - /** - * A level calc is already in progress - */ - IN_PROGRESS, - /** - * Results will be available - */ - AVAILABLE, - /** - * Result if calculation timed out - */ - TIMEOUT - } - final Multiset mdCount = HashMultiset.create(); - final Multiset entityCount = HashMultiset.create(); - - final Result state; - - public Results(Result state) { - this.state = state; - } - - public Results() { - this.state = Result.AVAILABLE; - } - /** - * @return the mdCount - */ - public Multiset getMdCount() { - return mdCount; - } - - /** - * @return the state - */ - public Result getState() { - return state; - } - - /** - * @return the entityCount - */ - public Multiset getEntityCount() { - return entityCount; - } - -} \ No newline at end of file +package world.bentobox.limits.calculators; + +import java.util.EnumMap; +import java.util.Map; + +import org.bukkit.NamespacedKey; +import org.bukkit.World.Environment; +import org.bukkit.entity.EntityType; + +import com.google.common.collect.HashMultiset; +import com.google.common.collect.Multiset; + +public class Results { + public enum Result { + IN_PROGRESS, + AVAILABLE, + TIMEOUT + } + + /** Block counts per environment, populated by the chunk scan. */ + final Map> envBlockCount = new EnumMap<>(Environment.class); + /** Entity counts per environment, populated by the entity scan. */ + final Map> envEntityCount = new EnumMap<>(Environment.class); + + final Result state; + + public Results(Result state) { + this.state = state; + } + + public Results() { + this.state = Result.AVAILABLE; + } + + public Multiset getBlockCount(Environment env) { + return envBlockCount.computeIfAbsent(env, e -> HashMultiset.create()); + } + + public Multiset getEntityCount(Environment env) { + return envEntityCount.computeIfAbsent(env, e -> HashMultiset.create()); + } + + public Map> getEnvBlockCount() { + return envBlockCount; + } + + public Map> getEnvEntityCount() { + return envEntityCount; + } + + public Result getState() { + return state; + } +} diff --git a/src/main/java/world/bentobox/limits/commands/admin/CalcCommand.java b/src/main/java/world/bentobox/limits/commands/admin/CalcCommand.java index fc53cf4..b20fc58 100644 --- a/src/main/java/world/bentobox/limits/commands/admin/CalcCommand.java +++ b/src/main/java/world/bentobox/limits/commands/admin/CalcCommand.java @@ -20,7 +20,6 @@ public class CalcCommand extends CompositeCommand { private final Limits addon; - private Island island; /** * Admin command @@ -54,7 +53,7 @@ public boolean execute(User user, String label, List args) { user.sendMessage("general.errors.unknown-player", args.get(0)); return true; } - island = addon.getIslands().getIsland(getWorld(), playerUUID); + Island island = addon.getIslands().getIsland(getWorld(), playerUUID); if (island == null) { user.sendMessage("general.errors.player-has-no-island"); return false; @@ -77,9 +76,10 @@ private void handlePipelineResult(User user, Results results) { if (results == null) { user.sendMessage("island.limits.recount.in-progress"); } else { - switch (results.getState()) { - case TIMEOUT -> user.sendMessage("admin.limits.calc.timeout"); - default -> user.sendMessage("admin.limits.calc.finished"); + if (results.getState() == Results.Result.TIMEOUT) { + user.sendMessage("admin.limits.calc.timeout"); + } else { + user.sendMessage("admin.limits.calc.finished"); } } } diff --git a/src/main/java/world/bentobox/limits/commands/admin/OffsetCommand.java b/src/main/java/world/bentobox/limits/commands/admin/OffsetCommand.java index c71ab37..c173ef9 100644 --- a/src/main/java/world/bentobox/limits/commands/admin/OffsetCommand.java +++ b/src/main/java/world/bentobox/limits/commands/admin/OffsetCommand.java @@ -151,8 +151,8 @@ public boolean execute(User user, String label, List args) int offset = Integer.parseInt(args.get(2)); - if (material != null && offset == islandData.getBlockLimitOffset(material.getKey()) || - entityType != null && offset == islandData.getEntityLimitOffset(entityType)) + if (material != null && offset == islandData.getBlockLimitOffset(org.bukkit.World.Environment.NORMAL, material.getKey()) || + entityType != null && offset == islandData.getEntityLimitOffset(org.bukkit.World.Environment.NORMAL, entityType)) { user.sendMessage("admin.limits.offset.set.same", TextVariables.NAME, @@ -164,7 +164,7 @@ public boolean execute(User user, String label, List args) if (material != null) { - islandData.setBlockLimitsOffset(material.getKey(), offset); + islandData.setBlockLimitsOffsetAllEnvs(material.getKey(), offset); islandData.setChanged(); user.sendMessage("admin.limits.offset.set.success", @@ -173,7 +173,7 @@ public boolean execute(User user, String label, List args) } else { - islandData.setEntityLimitsOffset(entityType, offset); + islandData.setEntityLimitsOffsetAllEnvs(entityType, offset); islandData.setChanged(); user.sendMessage("admin.limits.offset.set.success", @@ -188,7 +188,7 @@ public boolean execute(User user, String label, List args) @Override public Optional> tabComplete(User user, String alias, List args) { - return OffsetCommand.craftTabComplete(user, alias, args); + return OffsetCommand.craftTabComplete(user, args); } @@ -277,9 +277,9 @@ public boolean execute(User user, String label, List args) if (material != null) { - offset += islandData.getBlockLimitOffset(material.getKey()); + offset += islandData.getBlockLimitOffset(org.bukkit.World.Environment.NORMAL, material.getKey()); - islandData.setBlockLimitsOffset(material.getKey(), offset); + islandData.setBlockLimitsOffsetAllEnvs(material.getKey(), offset); islandData.setChanged(); user.sendMessage("admin.limits.offset.add.success", @@ -288,9 +288,9 @@ public boolean execute(User user, String label, List args) } else { - offset += islandData.getEntityLimitOffset(entityType); + offset += islandData.getEntityLimitOffset(org.bukkit.World.Environment.NORMAL, entityType); - islandData.setEntityLimitsOffset(entityType, offset); + islandData.setEntityLimitsOffsetAllEnvs(entityType, offset); islandData.setChanged(); user.sendMessage("admin.limits.offset.add.success", @@ -305,7 +305,7 @@ public boolean execute(User user, String label, List args) @Override public Optional> tabComplete(User user, String alias, List args) { - return OffsetCommand.craftTabComplete(user, alias, args); + return OffsetCommand.craftTabComplete(user, args); } @@ -394,9 +394,9 @@ public boolean execute(User user, String label, List args) if (material != null) { - offset = islandData.getBlockLimitOffset(material.getKey()) - offset; + offset = islandData.getBlockLimitOffset(org.bukkit.World.Environment.NORMAL, material.getKey()) - offset; - islandData.setBlockLimitsOffset(material.getKey(), offset); + islandData.setBlockLimitsOffsetAllEnvs(material.getKey(), offset); islandData.setChanged(); user.sendMessage("admin.limits.offset.remove.success", @@ -405,9 +405,9 @@ public boolean execute(User user, String label, List args) } else { - offset = islandData.getEntityLimitOffset(entityType) - offset; + offset = islandData.getEntityLimitOffset(org.bukkit.World.Environment.NORMAL, entityType) - offset; - islandData.setEntityLimitsOffset(entityType, offset); + islandData.setEntityLimitsOffsetAllEnvs(entityType, offset); islandData.setChanged(); user.sendMessage("admin.limits.offset.remove.success", @@ -422,7 +422,7 @@ public boolean execute(User user, String label, List args) @Override public Optional> tabComplete(User user, String alias, List args) { - return OffsetCommand.craftTabComplete(user, alias, args); + return OffsetCommand.craftTabComplete(user, args); } @@ -502,7 +502,7 @@ public boolean execute(User user, String label, List args) if (material != null) { - islandData.setBlockLimitsOffset(material.getKey(), 0); + islandData.setBlockLimitsOffsetAllEnvs(material.getKey(), 0); islandData.setChanged(); user.sendMessage("admin.limits.offset.reset.success", @@ -510,7 +510,7 @@ public boolean execute(User user, String label, List args) } else { - islandData.setEntityLimitsOffset(entityType, 0); + islandData.setEntityLimitsOffsetAllEnvs(entityType, 0); islandData.setChanged(); user.sendMessage("admin.limits.offset.reset.success", @@ -524,7 +524,7 @@ public boolean execute(User user, String label, List args) @Override public Optional> tabComplete(User user, String alias, List args) { - return OffsetCommand.craftTabComplete(user, alias, args); + return OffsetCommand.craftTabComplete(user, args); } @@ -604,14 +604,14 @@ public boolean execute(User user, String label, List args) if (material != null) { - int offset = islandData.getBlockLimitOffset(material.getKey()); + int offset = islandData.getBlockLimitOffset(org.bukkit.World.Environment.NORMAL, material.getKey()); user.sendMessage("admin.limits.offset.view.message", TextVariables.NAME, material.name(), TextVariables.NUMBER, String.valueOf(offset)); } else { - int offset = islandData.getEntityLimitOffset(entityType); + int offset = islandData.getEntityLimitOffset(org.bukkit.World.Environment.NORMAL, entityType); user.sendMessage("admin.limits.offset.view.message", TextVariables.NAME, entityType.name(), TextVariables.NUMBER, String.valueOf(offset)); @@ -624,7 +624,7 @@ public boolean execute(User user, String label, List args) @Override public Optional> tabComplete(User user, String alias, List args) { - return OffsetCommand.craftTabComplete(user, alias, args); + return OffsetCommand.craftTabComplete(user, args); } @@ -660,11 +660,10 @@ private static EntityType matchEntity(String name) /** * This method crafts tab complete for all subcommands * @param user User who runs command. - * @param alias Command alias. * @param args List of args. * @return Optional list of strings. */ - private static Optional> craftTabComplete(User user, String alias, List args) + private static Optional> craftTabComplete(User user, List args) { String lastArg = !args.isEmpty() ? args.get(args.size() - 1) : ""; @@ -686,7 +685,7 @@ else if (args.size() == 5) options.addAll(Arrays.stream(EntityType.values()). map(Enum::name). - collect(Collectors.toList())); + toList()); return Optional.of(Util.tabLimit(options, lastArg)); } diff --git a/src/main/java/world/bentobox/limits/commands/player/LimitPanel.java b/src/main/java/world/bentobox/limits/commands/player/LimitPanel.java index 8557526..81681b6 100644 --- a/src/main/java/world/bentobox/limits/commands/player/LimitPanel.java +++ b/src/main/java/world/bentobox/limits/commands/player/LimitPanel.java @@ -1,78 +1,88 @@ -package world.bentobox.limits.commands.player; - -import java.util.Map; -import java.util.UUID; - -import org.bukkit.Bukkit; -import org.bukkit.NamespacedKey; -import org.bukkit.World; -import org.bukkit.entity.Player; - -import world.bentobox.bentobox.api.addons.GameModeAddon; -import world.bentobox.bentobox.api.panels.builders.TabbedPanelBuilder; -import world.bentobox.bentobox.api.user.User; -import world.bentobox.bentobox.database.objects.Island; -import world.bentobox.limits.Limits; -import world.bentobox.limits.objects.IslandBlockCount; - -/** - * Shows a panel of the blocks that are limited and their status - * @author tastybento - * - */ -public class LimitPanel { - - private final Limits addon; - - /** - * @param addon - limit addon - */ - public LimitPanel(Limits addon) { - this.addon = addon; - } - - /** - * Show the limits panel - * @param gm - game mode - * @param user - user asking - * @param target - target uuid - */ - public void showLimits(GameModeAddon gm, User user, UUID target) { - // Get world - World world = gm.getOverWorld(); - // Get the island for the target - Island island = addon.getIslands().getIsland(world, target); - if (island == null) { - if (user.getUniqueId().equals(target)) { - user.sendMessage("general.errors.no-island"); - } else { - user.sendMessage("general.errors.player-has-no-island"); - } - return; - } - // See if the target is online - Player targetPlayer = Bukkit.getPlayer(target); - if (targetPlayer != null) { - // Update perms - addon.getJoinListener().checkPerms(targetPlayer, gm.getPermissionPrefix() + "island.limit.", island.getUniqueId(), gm.getDescription().getName()); - } - // Get the limits for this island - IslandBlockCount ibc = addon.getBlockLimitListener().getIsland(island.getUniqueId()); - Map matLimits = addon.getBlockLimitListener().getMaterialLimits(world, island.getUniqueId()); - if (matLimits.isEmpty() && addon.getSettings().getLimits().isEmpty()) { - user.sendMessage("island.limits.no-limits"); - return; - } - - new TabbedPanelBuilder() - .user(user) - .world(world) - .tab(0, new LimitTab(addon, ibc, matLimits, island, world, user, LimitTab.SORT_BY.A2Z)) - .tab(1, new LimitTab(addon, ibc, matLimits, island, world, user, LimitTab.SORT_BY.Z2A)) - .startingSlot(0) - .size(54) - .build().openPanel(); - - } - -} +package world.bentobox.limits.commands.player; + +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.World.Environment; +import org.bukkit.entity.Player; + +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.panels.builders.TabbedPanelBuilder; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.limits.Limits; +import world.bentobox.limits.objects.IslandBlockCount; + +/** + * Shows a panel of the limited blocks/entities and their counts per environment. + * + * @author tastybento + */ +public class LimitPanel { + + private final Limits addon; + + public LimitPanel(Limits addon) { + this.addon = addon; + } + + public void showLimits(GameModeAddon gm, User user, UUID target) { + World overWorld = gm.getOverWorld(); + Island island = addon.getIslands().getIsland(overWorld, target); + if (island == null) { + user.sendMessage(user.getUniqueId().equals(target) + ? "general.errors.no-island" + : "general.errors.player-has-no-island"); + return; + } + Player targetPlayer = Bukkit.getPlayer(target); + if (targetPlayer != null) { + addon.getJoinListener().checkPerms(targetPlayer, gm.getPermissionPrefix() + "island.limit.", + island.getUniqueId(), gm.getDescription().getName()); + } + IslandBlockCount ibc = addon.getBlockLimitListener().getIsland(island.getUniqueId()); + + TabbedPanelBuilder tpb = new TabbedPanelBuilder() + .user(user) + .world(overWorld) + .startingSlot(0) + .size(54); + + // Always show overworld; show nether/end only if the gamemode generates and provides them. + int slot = 0; + slot = addEnvTab(tpb, overWorld, ibc, island, user, Environment.NORMAL, slot); + World netherWorld = gm.getNetherWorld(); + if (netherWorld != null && addon.getPlugin().getIWM().isNetherIslands(overWorld)) { + slot = addEnvTab(tpb, netherWorld, ibc, island, user, Environment.NETHER, slot); + } + World endWorld = gm.getEndWorld(); + if (endWorld != null && addon.getPlugin().getIWM().isEndIslands(overWorld)) { + slot = addEnvTab(tpb, endWorld, ibc, island, user, Environment.THE_END, slot); + } + + Map overworldLimits = addon.getBlockLimitListener().getMaterialLimits(overWorld, + island.getUniqueId()); + if (overworldLimits.isEmpty() && addon.getSettings().getLimits(Environment.NORMAL).isEmpty() && slot == 0) { + user.sendMessage("island.limits.no-limits"); + return; + } + + tpb.build().openPanel(); + } + + private int addEnvTab(TabbedPanelBuilder tpb, World envWorld, IslandBlockCount ibc, Island island, + User user, Environment env, int slot) { + Map matLimits = addon.getBlockLimitListener().getMaterialLimits(envWorld, + island.getUniqueId()); + // Skip the env tab entirely if there is nothing to show for it. + if (matLimits.isEmpty() && addon.getSettings().getLimits(env).isEmpty() + && addon.getSettings().getGroupLimits(env).isEmpty()) { + return slot; + } + tpb.tab(slot, new LimitTab(addon, ibc, matLimits, envWorld, user, env)); + return slot + 1; + } +} diff --git a/src/main/java/world/bentobox/limits/commands/player/LimitTab.java b/src/main/java/world/bentobox/limits/commands/player/LimitTab.java index b824744..e5acaa8 100644 --- a/src/main/java/world/bentobox/limits/commands/player/LimitTab.java +++ b/src/main/java/world/bentobox/limits/commands/player/LimitTab.java @@ -1,271 +1,235 @@ -package world.bentobox.limits.commands.player; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Objects; -import java.util.stream.Collectors; - -import org.bukkit.Material; -import org.bukkit.NamespacedKey; -import org.bukkit.Registry; -import org.bukkit.World; -import org.bukkit.entity.EntityType; -import org.eclipse.jdt.annotation.Nullable; - -import com.google.common.collect.ImmutableMap; - -import world.bentobox.bentobox.api.localization.TextVariables; -import world.bentobox.bentobox.api.panels.PanelItem; -import world.bentobox.bentobox.api.panels.Tab; -import world.bentobox.bentobox.api.panels.builders.PanelItemBuilder; -import world.bentobox.bentobox.api.user.User; -import world.bentobox.bentobox.database.objects.Island; -import world.bentobox.bentobox.util.Util; -import world.bentobox.limits.EntityGroup; -import world.bentobox.limits.Limits; -import world.bentobox.limits.objects.IslandBlockCount; - -/** - * @author tastybento - * - */ -public class LimitTab implements Tab { - - enum SORT_BY { - A2Z, - Z2A - } - - /** - * This maps the entity types to the icon that should be shown in the panel - * If the icon is null, then the entity type is not covered by the addon - */ - private static final Map E2M = ImmutableMap.builder() - .put(EntityType.MOOSHROOM, Material.MOOSHROOM_SPAWN_EGG).put(EntityType.SNOW_GOLEM, Material.SNOW_BLOCK) - .put(EntityType.IRON_GOLEM, Material.IRON_BLOCK) - .put(EntityType.ILLUSIONER, Material.VILLAGER_SPAWN_EGG) - .put(EntityType.WITHER, Material.WITHER_SKELETON_SKULL) - //.put(EntityType.BOAT, Material.OAK_BOAT) - .put(EntityType.ARMOR_STAND, Material.ARMOR_STAND) - .put(EntityType.ITEM_FRAME, Material.ITEM_FRAME) - .put(EntityType.PAINTING, Material.PAINTING) - // Minecarts - .put(EntityType.TNT_MINECART, Material.TNT_MINECART).put(EntityType.CHEST_MINECART, Material.CHEST_MINECART) - .put(EntityType.COMMAND_BLOCK_MINECART, Material.COMMAND_BLOCK_MINECART) - .put(EntityType.FURNACE_MINECART, Material.FURNACE_MINECART) - .put(EntityType.HOPPER_MINECART, Material.HOPPER_MINECART) - .put(EntityType.SPAWNER_MINECART, Material.MINECART) - //.put(EntityType.CHEST_BOAT, Material.OAK_CHEST_BOAT) - .build(); - // This is a map of blocks to Items - private static final Map B2M; - static { - ImmutableMap.Builder builder = ImmutableMap.builder() - .put(Material.POTATOES.getKey(), Material.POTATO.getKey()) - .put(Material.CARROTS.getKey(), Material.CARROT.getKey()) - .put(Material.BEETROOTS.getKey(), Material.BEETROOT.getKey()) - .put(Material.REDSTONE_WIRE.getKey(), Material.REDSTONE.getKey()).put(Material.MELON_STEM.getKey(), Material.MELON.getKey()) - .put(Material.PUMPKIN_STEM.getKey(), Material.PUMPKIN.getKey()) - .put(Material.SWEET_BERRY_BUSH.getKey(), Material.SWEET_BERRIES.getKey()) - .put(Material.BAMBOO_SAPLING.getKey(), Material.BAMBOO.getKey()) - ; - B2M = builder.build(); - } - - private final World world; - private final User user; - private final Limits addon; - private final List<@Nullable PanelItem> result; - private final SORT_BY sortBy; - - public LimitTab(Limits addon, IslandBlockCount ibc, Map matLimits, Island island, World world, User user, SORT_BY sortBy) { - this.addon = addon; - this.world = world; - this.user = user; - this.sortBy = sortBy; - result = new ArrayList<>(); - addMaterialIcons(ibc, matLimits); - addEntityLimits(ibc, island); - addEntityGroupLimits(ibc, island); - // Sort - if (sortBy == SORT_BY.Z2A) { - result.sort((o1, o2) -> o2.getName().compareTo(o1.getName())); - } else { - result.sort(Comparator.comparing(PanelItem::getName)); - } - - } - - private void addEntityGroupLimits(IslandBlockCount ibc, Island island) { - // Entity group limits - Map groupMap = addon.getSettings().getGroupLimitDefinitions().stream().collect(Collectors.toMap(e -> e, EntityGroup::getLimit)); - // Group by same loop up map - Map groupByName = groupMap.keySet().stream().collect(Collectors.toMap(EntityGroup::getName, e -> e)); - // Merge in any permission-based limits - if (ibc == null) { - return; - } - ibc.getEntityGroupLimits().entrySet().stream() - .filter(e -> groupByName.containsKey(e.getKey())) - .forEach(e -> groupMap.put(groupByName.get(e.getKey()), e.getValue())); - // Update the group map for each group limit offset. If the value already exists add it - ibc.getEntityGroupLimitsOffset().forEach((key, value) -> { - if (groupByName.get(key) != null) { - groupMap.put(groupByName.get(key), (groupMap.getOrDefault(groupByName.get(key), 0) + value)); - } - }); - groupMap.forEach((v, limit) -> { - PanelItemBuilder pib = new PanelItemBuilder(); - pib.name(user.getTranslation("island.limits.panel.entity-group-name-syntax", TextVariables.NAME, - v.getName())); - String description = ""; - description += "(" + prettyNames(v) + ")\n"; - pib.icon(v.getIcon()); - long count = getCount(island, v); - String color = count >= limit ? user.getTranslation("island.limits.max-color") : user.getTranslation("island.limits.regular-color"); - description += color - + user.getTranslation("island.limits.block-limit-syntax", - TextVariables.NUMBER, String.valueOf(count), - "[limit]", String.valueOf(limit)); - pib.description(description); - result.add(pib.build()); - }); - } - - private void addEntityLimits(IslandBlockCount ibc, Island island) { - // Entity limits - Map map = new HashMap<>(addon.getSettings().getLimits()); - // Merge in any permission-based limits - if (ibc != null) { - map.putAll(ibc.getEntityLimits()); - ibc.getEntityLimitsOffset().forEach((k,v) -> map.put(k, map.getOrDefault(k, 0) + v)); - } - - map.forEach((k,v) -> { - PanelItemBuilder pib = new PanelItemBuilder(); - pib.name(user.getTranslation("island.limits.panel.entity-name-syntax", TextVariables.NAME, - Util.prettifyText(k.toString()))); - Material m; - try { - if (E2M.containsKey(k)) { - m = E2M.get(k); - } else if (k.isAlive()) { - m = Material.valueOf(k + "_SPAWN_EGG"); - } else { - // Regular material - m = Material.valueOf(k.toString()); - } - } catch (Exception e) { - m = Material.BARRIER; - } - pib.icon(m); - long count = getCount(island, k); - String color = count >= v ? user.getTranslation("island.limits.max-color") : user.getTranslation("island.limits.regular-color"); - pib.description(color - + user.getTranslation("island.limits.block-limit-syntax", - TextVariables.NUMBER, String.valueOf(count), - "[limit]", String.valueOf(v))); - result.add(pib.build()); - }); - - } - - private void addMaterialIcons(IslandBlockCount ibc, Map matLimits) { - // Material limits - for (Entry en : matLimits.entrySet()) { - PanelItemBuilder pib = new PanelItemBuilder(); - pib.name(user.getTranslation("island.limits.panel.block-name-syntax", TextVariables.NAME, - Util.prettifyText(en.getKey().getKey()))); - // Adjust icon - Material mat = Registry.MATERIAL.get(B2M.getOrDefault(en.getKey(), en.getKey())); - pib.icon(Objects.requireNonNullElse(mat, Material.PAPER)); - - int count = ibc == null ? 0 : ibc.getBlockCounts().getOrDefault(en.getKey(), 0); - int value = en.getValue(); - String color = count >= value ? user.getTranslation("island.limits.max-color") : user.getTranslation("island.limits.regular-color"); - pib.description(color - + user.getTranslation("island.limits.block-limit-syntax", - TextVariables.NUMBER, String.valueOf(count), - "[limit]", String.valueOf(value))); - result.add(pib.build()); - } - } - - @Override - public PanelItem getIcon() { - return new PanelItemBuilder().icon(Material.MAGENTA_GLAZED_TERRACOTTA).name(this.getName()).build(); - } - - @Override - public String getName() { - String sort = user.getTranslation(world, "island.limits.panel." + sortBy); - return user.getTranslation(world, "island.limits.panel.title-syntax", "[title]", - user.getTranslation(world, "limits.panel-title"), "[sort]", sort); - } - - @Override - public List<@Nullable PanelItem> getPanelItems() { - return result; - } - - @Override - public String getPermission() { - return ""; - } - - private String prettyNames(EntityGroup v) { - StringBuilder sb = new StringBuilder(); - List l = new ArrayList<>(v.getTypes()); - for(int i = 0; i < l.size(); i++) - { - sb.append(Util.prettifyText(l.get(i).toString())); - if (i + 1 < l.size()) - sb.append(", "); - if((i+1) % 5 == 0) - sb.append("\n"); - } - return sb.toString(); - } - - long getCount(Island island, EntityType ent) { - long count = island.getWorld().getEntities().stream() - .filter(e -> e.getType().equals(ent)) - .filter(e -> island.inIslandSpace(e.getLocation())).count(); - // Nether - if (addon.getPlugin().getIWM().isNetherIslands(island.getWorld()) && addon.getPlugin().getIWM().getNetherWorld(island.getWorld()) != null) { - count += addon.getPlugin().getIWM().getNetherWorld(island.getWorld()).getEntities().stream() - .filter(e -> e.getType().equals(ent)) - .filter(e -> island.inIslandSpace(e.getLocation())).count(); - } - // End - if (addon.getPlugin().getIWM().isEndIslands(island.getWorld()) && addon.getPlugin().getIWM().getEndWorld(island.getWorld()) != null) { - count += addon.getPlugin().getIWM().getEndWorld(island.getWorld()).getEntities().stream() - .filter(e -> e.getType().equals(ent)) - .filter(e -> island.inIslandSpace(e.getLocation())).count(); - } - return count; - } - - long getCount(Island island, EntityGroup group) { - long count = island.getWorld().getEntities().stream() - .filter(e -> group.contains(e.getType())) - .filter(e -> island.inIslandSpace(e.getLocation())).count(); - // Nether - if (addon.getPlugin().getIWM().isNetherIslands(island.getWorld()) && addon.getPlugin().getIWM().getNetherWorld(island.getWorld()) != null) { - count += addon.getPlugin().getIWM().getNetherWorld(island.getWorld()).getEntities().stream() - .filter(e -> group.contains(e.getType())) - .filter(e -> island.inIslandSpace(e.getLocation())).count(); - } - // End - if (addon.getPlugin().getIWM().isEndIslands(island.getWorld()) && addon.getPlugin().getIWM().getEndWorld(island.getWorld()) != null) { - count += addon.getPlugin().getIWM().getEndWorld(island.getWorld()).getEntities().stream() - .filter(e -> group.contains(e.getType())) - .filter(e -> island.inIslandSpace(e.getLocation())).count(); - } - return count; - } -} +package world.bentobox.limits.commands.player; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.World; +import org.bukkit.World.Environment; +import org.bukkit.entity.EntityType; +import org.eclipse.jdt.annotation.Nullable; + +import com.google.common.collect.ImmutableMap; + +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.panels.PanelItem; +import world.bentobox.bentobox.api.panels.Tab; +import world.bentobox.bentobox.api.panels.builders.PanelItemBuilder; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; +import world.bentobox.limits.EntityGroup; +import world.bentobox.limits.Limits; +import world.bentobox.limits.objects.IslandBlockCount; + +/** + * One tab of the limits panel. Shows counts and limits for a single environment. + * + * @author tastybento + */ +public class LimitTab implements Tab { + + private static final String MAX_COLOR_KEY = "island.limits.max-color"; + private static final String REGULAR_COLOR_KEY = "island.limits.regular-color"; + private static final String BLOCK_LIMIT_SYNTAX_KEY = "island.limits.block-limit-syntax"; + private static final String LIMIT_PLACEHOLDER = "[limit]"; + + /** This maps the entity types to the icon that should be shown in the panel */ + private static final Map E2M = ImmutableMap.builder() + .put(EntityType.MOOSHROOM, Material.MOOSHROOM_SPAWN_EGG).put(EntityType.SNOW_GOLEM, Material.SNOW_BLOCK) + .put(EntityType.IRON_GOLEM, Material.IRON_BLOCK) + .put(EntityType.ILLUSIONER, Material.VILLAGER_SPAWN_EGG) + .put(EntityType.WITHER, Material.WITHER_SKELETON_SKULL) + .put(EntityType.ARMOR_STAND, Material.ARMOR_STAND) + .put(EntityType.ITEM_FRAME, Material.ITEM_FRAME) + .put(EntityType.PAINTING, Material.PAINTING) + .put(EntityType.TNT_MINECART, Material.TNT_MINECART).put(EntityType.CHEST_MINECART, Material.CHEST_MINECART) + .put(EntityType.COMMAND_BLOCK_MINECART, Material.COMMAND_BLOCK_MINECART) + .put(EntityType.FURNACE_MINECART, Material.FURNACE_MINECART) + .put(EntityType.HOPPER_MINECART, Material.HOPPER_MINECART) + .put(EntityType.SPAWNER_MINECART, Material.MINECART) + .build(); + + private static final Map B2M; + static { + B2M = ImmutableMap.builder() + .put(Material.POTATOES.getKey(), Material.POTATO.getKey()) + .put(Material.CARROTS.getKey(), Material.CARROT.getKey()) + .put(Material.BEETROOTS.getKey(), Material.BEETROOT.getKey()) + .put(Material.REDSTONE_WIRE.getKey(), Material.REDSTONE.getKey()) + .put(Material.MELON_STEM.getKey(), Material.MELON.getKey()) + .put(Material.PUMPKIN_STEM.getKey(), Material.PUMPKIN.getKey()) + .put(Material.SWEET_BERRY_BUSH.getKey(), Material.SWEET_BERRIES.getKey()) + .put(Material.BAMBOO_SAPLING.getKey(), Material.BAMBOO.getKey()) + .build(); + } + + private final World world; + private final User user; + private final Limits addon; + private final Environment env; + private final List<@Nullable PanelItem> result; + + public LimitTab(Limits addon, IslandBlockCount ibc, Map matLimits, + World world, User user, Environment env) { + this.addon = addon; + this.world = world; + this.user = user; + this.env = env; + result = new ArrayList<>(); + addMaterialIcons(ibc, matLimits); + addEntityLimits(ibc); + addEntityGroupLimits(ibc); + result.sort(Comparator.comparing(PanelItem::getName)); + } + + private void addEntityGroupLimits(IslandBlockCount ibc) { + Map envGroupLimits = new HashMap<>(addon.getSettings().getGroupLimits(env)); + Map groupMap = addon.getSettings().getGroupLimitDefinitions().stream() + .filter(g -> envGroupLimits.containsKey(g.getName())) + .collect(Collectors.toMap(g -> g, g -> envGroupLimits.get(g.getName()))); + Map groupByName = addon.getSettings().getGroupLimitDefinitions().stream() + .collect(Collectors.toMap(EntityGroup::getName, e -> e)); + if (ibc != null) { + ibc.getEntityGroupLimits(env).forEach((name, limit) -> { + EntityGroup g = groupByName.get(name); + if (g != null) groupMap.put(g, limit); + }); + ibc.getEntityGroupLimitsOffset(env).forEach((name, offset) -> { + EntityGroup g = groupByName.get(name); + if (g != null) groupMap.put(g, groupMap.getOrDefault(g, 0) + offset); + }); + } + groupMap.forEach((g, limit) -> { + PanelItemBuilder pib = new PanelItemBuilder(); + pib.name(user.getTranslation("island.limits.panel.entity-group-name-syntax", TextVariables.NAME, + g.getName())); + String description = "(" + prettyNames(g) + ")\n"; + pib.icon(g.getIcon()); + int count = ibc == null ? 0 : sumGroupCount(ibc, g); + String color = count >= limit ? user.getTranslation(MAX_COLOR_KEY) + : user.getTranslation(REGULAR_COLOR_KEY); + description += color + user.getTranslation(BLOCK_LIMIT_SYNTAX_KEY, + TextVariables.NUMBER, String.valueOf(count), + LIMIT_PLACEHOLDER, String.valueOf(limit)); + pib.description(description); + result.add(pib.build()); + }); + } + + private int sumGroupCount(IslandBlockCount ibc, EntityGroup group) { + Map counts = ibc.getEntityCounts(env); + int total = 0; + for (EntityType t : group.getTypes()) { + total += counts.getOrDefault(t, 0); + } + return total; + } + + private void addEntityLimits(IslandBlockCount ibc) { + Map map = new EnumMap<>(EntityType.class); + map.putAll(addon.getSettings().getLimits(env)); + if (ibc != null) { + map.putAll(ibc.getEntityLimits(env)); + ibc.getEntityLimitsOffset(env).forEach((k, v) -> map.put(k, map.getOrDefault(k, 0) + v)); + } + map.forEach((k, v) -> { + PanelItemBuilder pib = new PanelItemBuilder(); + pib.name(user.getTranslation("island.limits.panel.entity-name-syntax", TextVariables.NAME, + Util.prettifyText(k.toString()))); + Material m; + try { + if (E2M.containsKey(k)) { + m = E2M.get(k); + } else if (k.isAlive()) { + m = Material.valueOf(k + "_SPAWN_EGG"); + } else { + m = Material.valueOf(k.toString()); + } + } catch (Exception e) { + m = Material.BARRIER; + } + pib.icon(m); + int count = ibc == null ? 0 : ibc.getEntityCount(env, k); + String color = count >= v ? user.getTranslation(MAX_COLOR_KEY) + : user.getTranslation(REGULAR_COLOR_KEY); + pib.description(color + user.getTranslation(BLOCK_LIMIT_SYNTAX_KEY, + TextVariables.NUMBER, String.valueOf(count), + LIMIT_PLACEHOLDER, String.valueOf(v))); + result.add(pib.build()); + }); + } + + private void addMaterialIcons(IslandBlockCount ibc, Map matLimits) { + for (Entry en : matLimits.entrySet()) { + PanelItemBuilder pib = new PanelItemBuilder(); + pib.name(user.getTranslation("island.limits.panel.block-name-syntax", TextVariables.NAME, + Util.prettifyText(en.getKey().getKey()))); + Material mat = Registry.MATERIAL.get(B2M.getOrDefault(en.getKey(), en.getKey())); + pib.icon(Objects.requireNonNullElse(mat, Material.PAPER)); + + int count = ibc == null ? 0 : ibc.getBlockCount(env, en.getKey()); + int value = en.getValue(); + String color = count >= value ? user.getTranslation(MAX_COLOR_KEY) + : user.getTranslation(REGULAR_COLOR_KEY); + pib.description(color + user.getTranslation(BLOCK_LIMIT_SYNTAX_KEY, + TextVariables.NUMBER, String.valueOf(count), + LIMIT_PLACEHOLDER, String.valueOf(value))); + result.add(pib.build()); + } + } + + @Override + public PanelItem getIcon() { + Material icon = switch (env) { + case NETHER -> Material.NETHERRACK; + case THE_END -> Material.END_STONE; + default -> Material.GRASS_BLOCK; + }; + return new PanelItemBuilder().icon(icon).name(this.getName()).build(); + } + + @Override + public String getName() { + return user.getTranslation(world, "island.limits.panel.title-syntax", + "[title]", user.getTranslation(world, "limits.panel-title"), + "[env]", user.getTranslation(world, envKey())); + } + + private String envKey() { + return switch (env) { + case NETHER -> "island.limits.panel.env-nether"; + case THE_END -> "island.limits.panel.env-end"; + default -> "island.limits.panel.env-overworld"; + }; + } + + @Override + public List<@Nullable PanelItem> getPanelItems() { + return result; + } + + @Override + public String getPermission() { + return ""; + } + + private String prettyNames(EntityGroup v) { + StringBuilder sb = new StringBuilder(); + List l = new ArrayList<>(v.getTypes()); + for (int i = 0; i < l.size(); i++) { + sb.append(Util.prettifyText(l.get(i).toString())); + if (i + 1 < l.size()) sb.append(", "); + if ((i + 1) % 5 == 0) sb.append("\n"); + } + return sb.toString(); + } +} diff --git a/src/main/java/world/bentobox/limits/commands/player/RecountCommand.java b/src/main/java/world/bentobox/limits/commands/player/RecountCommand.java index 4b466b4..e75d800 100644 --- a/src/main/java/world/bentobox/limits/commands/player/RecountCommand.java +++ b/src/main/java/world/bentobox/limits/commands/player/RecountCommand.java @@ -9,6 +9,7 @@ import world.bentobox.bentobox.database.objects.Island; import world.bentobox.limits.Limits; import world.bentobox.limits.calculators.Pipeliner; +import world.bentobox.limits.calculators.Results; /** * @@ -65,9 +66,10 @@ public boolean execute(User user, String label, List args) { if (results == null) { user.sendMessage("island.limits.recount.in-progress"); } else { - switch (results.getState()) { - case TIMEOUT -> user.sendMessage("admin.limits.calc.timeout"); - default -> user.sendMessage("admin.limits.calc.finished"); + if (results.getState() == Results.Result.TIMEOUT) { + user.sendMessage("admin.limits.calc.timeout"); + } else { + user.sendMessage("admin.limits.calc.finished"); } } }); diff --git a/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java b/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java index feaa37f..254f279 100644 --- a/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java +++ b/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java @@ -1,600 +1,569 @@ -package world.bentobox.limits.listeners; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; - -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.NamespacedKey; -import org.bukkit.Registry; -import org.bukkit.Tag; -import org.bukkit.World; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; -import org.bukkit.block.data.BlockData; -import org.bukkit.block.data.type.TechnicalPiston; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.event.Cancellable; -import org.bukkit.event.Event; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.block.Action; -import org.bukkit.event.block.BlockBreakEvent; -import org.bukkit.event.block.BlockBurnEvent; -import org.bukkit.event.block.BlockExplodeEvent; -import org.bukkit.event.block.BlockFadeEvent; -import org.bukkit.event.block.BlockFormEvent; -import org.bukkit.event.block.BlockFromToEvent; -import org.bukkit.event.block.BlockGrowEvent; -import org.bukkit.event.block.BlockMultiPlaceEvent; -import org.bukkit.event.block.BlockPlaceEvent; -import org.bukkit.event.block.BlockSpreadEvent; -import org.bukkit.event.block.EntityBlockFormEvent; -import org.bukkit.event.block.LeavesDecayEvent; -import org.bukkit.event.entity.EntityChangeBlockEvent; -import org.bukkit.event.entity.EntityExplodeEvent; -import org.bukkit.event.player.PlayerInteractEvent; -import org.eclipse.jdt.annotation.NonNull; -import org.eclipse.jdt.annotation.Nullable; - -import world.bentobox.bentobox.api.events.island.IslandDeleteEvent; -import world.bentobox.bentobox.api.localization.TextVariables; -import world.bentobox.bentobox.api.user.User; -import world.bentobox.bentobox.database.Database; -import world.bentobox.bentobox.database.objects.Island; -import world.bentobox.bentobox.util.Util; -import world.bentobox.limits.Limits; -import world.bentobox.limits.objects.IslandBlockCount; - -/** - * @author tastybento - * - */ -public class BlockLimitsListener implements Listener { - - /** - * Blocks that are not counted - */ - private static final List DO_NOT_COUNT = List.of(Material.LAVA.getKey(), Material.WATER.getKey(), Material.AIR.getKey(), Material.FIRE.getKey(), Material.END_PORTAL.getKey(), Material.NETHER_PORTAL.getKey()); - private static final List STACKABLE = List.of(Material.SUGAR_CANE.getKey(), Material.BAMBOO.getKey()); - - /** - * Save every 10 blocks of change - */ - private static final Integer CHANGE_LIMIT = 9; - private final Limits addon; - private final Map islandCountMap = new HashMap<>(); - private final Map saveMap = new HashMap<>(); - private final Database handler; - private final Map> worldLimitMap = new HashMap<>(); - private Map defaultLimitMap = new HashMap<>(); - - public BlockLimitsListener(Limits addon) { - this.addon = addon; - handler = new Database<>(addon, IslandBlockCount.class); - List toBeDeleted = new ArrayList<>(); - handler.loadObjects().forEach(ibc -> { - // Clean up - if (addon.isCoveredGameMode(ibc.getGameMode())) { - ibc.getBlockCounts().keySet().removeIf(DO_NOT_COUNT::contains); - // Store - islandCountMap.put(ibc.getUniqueId(), ibc); - } else { - toBeDeleted.add(ibc.getUniqueId()); - } - }); - toBeDeleted.forEach(handler::deleteID); - loadAllLimits(); - } - - /** - * Loads the default and world-specific limits - */ - private void loadAllLimits() { - // Load the default limits - addon.log("Loading default limits"); - if (addon.getConfig().isConfigurationSection("blocklimits")) { - ConfigurationSection limitConfig = addon.getConfig().getConfigurationSection("blocklimits"); - defaultLimitMap = loadLimits(Objects.requireNonNull(limitConfig)); - } - - // Load specific worlds - if (addon.getConfig().isConfigurationSection("worlds")) { - ConfigurationSection worlds = addon.getConfig().getConfigurationSection("worlds"); - for (String worldName : Objects.requireNonNull(worlds).getKeys(false)) { - World world = Bukkit.getWorld(worldName); - if (world != null && addon.inGameModeWorld(world)) { - addon.log("Loading limits for " + world.getName()); - worldLimitMap.putIfAbsent(world, new HashMap<>()); - ConfigurationSection matsConfig = worlds.getConfigurationSection(worldName); - worldLimitMap.put(world, loadLimits(Objects.requireNonNull(matsConfig))); - } - } - } - - } - /* - private static final Set> TAGS = Set.of(Tag.ANVIL, - Tag.BAMBOO_BLOCKS, Tag.BANNERS, Tag.BEDS, Tag.BEEHIVES, Tag.BUTTONS, Tag.CAMPFIRES, Tag.CANDLE_CAKES, - Tag.CORALS, Tag.CAULDRONS, Tag.CAVE_VINES, Tag.DIRT, Tag.DOORS, - Tag.FENCE_GATES, Tag.FENCES, Tag.ICE, Tag.LEAVES, Tag.LOGS, - Tag.NYLIUM, Tag.PLANKS, Tag.PORTALS, Tag.RAILS, Tag.SAND, Tag.SAPLINGS, Tag.SHULKER_BOXES, - Tag.SIGNS, Tag.SLABS, Tag.SNOW, Tag.STAIRS, Tag.STONE_BRICKS, - Tag.PRESSURE_PLATES, Tag.TERRACOTTA, Tag.TRAPDOORS, Tag.WALLS, - Tag.WART_BLOCKS, Tag.WOOL, Tag.WOOL_CARPETS);*/ - /** - * Loads limit map from configuration section - * - * @param cs - configuration section - * @return limit map - */ - private Map loadLimits(ConfigurationSection cs) { - Map limits = new HashMap<>(); - for (String key : cs.getKeys(false)) { - int limit = cs.getInt(key); - NamespacedKey nsKey; - if (key.contains(":")) { - // Config already has a full namespaced key - nsKey = NamespacedKey.fromString(key.toLowerCase(Locale.ROOT)); - if (nsKey == null) { - Bukkit.getLogger().warning("Invalid namespaced key in config, skipping: " + key); - continue; - } - } else { - // Assume "minecraft" namespace if none provided - nsKey = new NamespacedKey(NamespacedKey.MINECRAFT, key.toLowerCase(Locale.ROOT)); - } - - boolean matched = false; - - // Try match to Material - only accept block materials that are not in DO_NOT_COUNT - Material mat = Registry.MATERIAL.get(nsKey); - if (mat != null) { - matched = true; - if (!mat.isBlock()) { - Bukkit.getLogger().warning("Non-block material in block limits config: " + key); - } else if (DO_NOT_COUNT.contains(mat.getKey())) { - Bukkit.getLogger().warning("Uncountable material in block limits config: " + key); - } else { - limits.put(mat.getKey(), limit); - } - } - - // Try match to a Tag - if (!matched) { - Tag tag = Bukkit.getTag("blocks", nsKey, Material.class); - if (tag != null) { - limits.put(tag.getKey(), limit); - matched = true; - } - } - - - // Log warning if nothing matched - if (!matched) { - Bukkit.getLogger().warning("Unknown material or tag in config: " + key); - } - } - return limits; - } - - - /** - * Save the count database completely - */ - public void save() { - islandCountMap.values().stream().filter(IslandBlockCount::isChanged).forEach(handler::saveObjectAsync); - } - - // Player-related events - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(BlockPlaceEvent e) { - notify(e, User.getInstance(e.getPlayer()), process(e.getBlock(), true), e.getBlock().getType()); - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(BlockBreakEvent e) { - if (e.getBlock().hasMetadata("blockbreakevent-ignore")) { - // Ignore event due to Advanced Enchantments. See https://ae.advancedplugins.net/for-developers/plugin-compatiblity-issues - // @since 1.28.0 - return; - } - handleBreak(e, e.getBlock()); - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onTurtleEggBreak(PlayerInteractEvent e) { - if (e.getAction().equals(Action.PHYSICAL) && e.getClickedBlock() != null && e.getClickedBlock().getType().equals(Material.TURTLE_EGG)) { - handleBreak(e, e.getClickedBlock()); - } - } - - private void handleBreak(Event e, Block b) { - if (!addon.inGameModeWorld(b.getWorld())) { - return; - } - Material mat = b.getType(); - - // Check for stackable plants - if (STACKABLE.contains(b.getType().getKey())) { - // Check for blocks above - Block block = b; - while(block.getRelative(BlockFace.UP).getType().equals(mat) && block.getY() < b.getWorld().getMaxHeight()) { - block = block.getRelative(BlockFace.UP); - process(block, false); - } - } - process(b, false); - // Player breaks a block and there was a redstone dust/repeater/... above - if (b.getRelative(BlockFace.UP).getType() == Material.REDSTONE_WIRE || b.getRelative(BlockFace.UP).getType() == Material.REPEATER || b.getRelative(BlockFace.UP).getType() == Material.COMPARATOR || b.getRelative(BlockFace.UP).getType() == Material.REDSTONE_TORCH) { - process(b.getRelative(BlockFace.UP), false); - } - if (b.getRelative(BlockFace.EAST).getType() == Material.REDSTONE_WALL_TORCH) { - process(b.getRelative(BlockFace.EAST), false); - } - if (b.getRelative(BlockFace.WEST).getType() == Material.REDSTONE_WALL_TORCH) { - process(b.getRelative(BlockFace.WEST), false); - } - if (b.getRelative(BlockFace.SOUTH).getType() == Material.REDSTONE_WALL_TORCH) { - process(b.getRelative(BlockFace.SOUTH), false); - } - if (b.getRelative(BlockFace.NORTH).getType() == Material.REDSTONE_WALL_TORCH) { - process(b.getRelative(BlockFace.NORTH), false); - } - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(BlockMultiPlaceEvent e) { - notify(e, User.getInstance(e.getPlayer()), process(e.getBlock(), true), e.getBlock().getType()); - } - - /** - * Cancel the event and notify the user of failure - * @param e event - * @param user user - * @param limit maximum limit allowed - * @param m material - */ - private void notify(Cancellable e, User user, int limit, Material m) { - if (limit > -1) { - user.notify("block-limits.hit-limit", - "[material]", Util.prettifyText(m.toString()), - TextVariables.NUMBER, String.valueOf(limit)); - e.setCancelled(true); - } - } - - // Non-player events - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(BlockBurnEvent e) { - process(e.getBlock(), false); - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(BlockExplodeEvent e) { - e.blockList().forEach(b -> process(b, false)); - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(BlockFadeEvent e) { - process(e.getBlock(), false); - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(BlockFormEvent e) { - // EntityBlockFormEvent and BlockSpreadEvent extend BlockFormEvent; skip here to avoid double-processing - if (e instanceof EntityBlockFormEvent || e instanceof BlockSpreadEvent) { - return; - } - // Remove the old block state count - process(e.getBlock(), false); - // Add the new block state count; cancel if the new state exceeds its limit - if (process(e.getBlock(), e.getNewState().getBlockData(), true) > -1) { - e.setCancelled(true); - process(e.getBlock(), true); // Restore old count - } - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(BlockSpreadEvent e) { - process(e.getBlock(), true); - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(EntityBlockFormEvent e) { - // Remove the old block state count - process(e.getBlock(), false); - // Add the new block state count; cancel if the new state exceeds its limit - if (process(e.getBlock(), e.getNewState().getBlockData(), true) > -1) { - e.setCancelled(true); - process(e.getBlock(), true); // Restore old count - } - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(BlockGrowEvent e) { - if (process(e.getNewState().getBlock(), true) > -1) { - e.setCancelled(true); - e.getBlock().getWorld().getBlockAt(e.getBlock().getLocation()).setBlockData(e.getBlock().getBlockData()); - } else { - process(e.getBlock(), false); - } - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(LeavesDecayEvent e) { - process(e.getBlock(), false); - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(EntityExplodeEvent e) { - e.blockList().forEach(b -> process(b, false)); - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(EntityChangeBlockEvent e) { - // First, if the entity is changing the block to a non-AIR material, - // attempt to add the new block state and enforce limits. - if (e.getTo() != Material.AIR) { - int limit = process(e.getBlock(), e.getBlockData(), true); - // If a non-negative value is returned, the limit would be exceeded. - // Cancel the event and keep the existing block/counts unchanged. - if (limit > -1) { - e.setCancelled(true); - return; - } - } - - // At this point either the block is being removed (to AIR) or the new - // state was accepted. Now remove the old block from the counts. - process(e.getBlock(), false); - - // If the block was farmland and the change is allowed, also remove the - // block above (e.g., crops) from the counts. - if (!e.isCancelled() && e.getBlock().getType().equals(Material.FARMLAND)) { - process(e.getBlock().getRelative(BlockFace.UP), false); - } - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onBlock(BlockFromToEvent e) { - if (e.getBlock().isLiquid() - && (e.getToBlock().getType() == Material.REDSTONE_WIRE - || e.getToBlock().getType() == Material.REPEATER - || e.getToBlock().getType() == Material.COMPARATOR - || e.getToBlock().getType() == Material.REDSTONE_TORCH - || e.getToBlock().getType() == Material.REDSTONE_WALL_TORCH)) { - process(e.getToBlock(), false); - } - } - - /** - * Return equivalents. Maps things like wall materials to their non-wall equivalents - * @param b block data - * @return material that matches the block data - */ - public NamespacedKey fixMaterial(BlockData b) { - Material mat = b.getMaterial(); - if (mat.equals(Material.CHIPPED_ANVIL) || mat.equals(Material.DAMAGED_ANVIL)) { - return Material.ANVIL.getKey(); - } else if (mat == Material.REDSTONE_WALL_TORCH) { - return Material.REDSTONE_TORCH.getKey(); - } else if (mat == Material.WALL_TORCH) { - return Material.TORCH.getKey(); - } else if (mat == Material.COPPER_WALL_TORCH) { - return Material.COPPER_TORCH.getKey(); - } else if (mat == Material.ZOMBIE_WALL_HEAD) { - return Material.ZOMBIE_HEAD.getKey(); - } else if (mat == Material.CREEPER_WALL_HEAD) { - return Material.CREEPER_HEAD.getKey(); - } else if (mat == Material.PLAYER_WALL_HEAD) { - return Material.PLAYER_HEAD.getKey(); - } else if (mat == Material.DRAGON_WALL_HEAD) { - return Material.DRAGON_HEAD.getKey(); - } else if (mat == Material.BAMBOO_SAPLING) { - return Material.BAMBOO.getKey(); - } else if (mat == Material.PISTON_HEAD || mat == Material.MOVING_PISTON) { - TechnicalPiston tp = (TechnicalPiston) b; - if (tp.getType() == TechnicalPiston.Type.NORMAL) { - return Material.PISTON.getKey(); - } else { - return Material.STICKY_PISTON.getKey(); - } - } else if (mat == Material.EXPOSED_COPPER_CHEST || mat == Material.WEATHERED_COPPER_CHEST - || mat == Material.OXIDIZED_COPPER_CHEST || mat == Material.WAXED_COPPER_CHEST - || mat == Material.WAXED_EXPOSED_COPPER_CHEST || mat == Material.WAXED_WEATHERED_COPPER_CHEST - || mat == Material.WAXED_OXIDIZED_COPPER_CHEST) { - return Material.COPPER_CHEST.getKey(); - } - return mat.getKey(); - } - - /** - * Check if a block can be - * - * @param b - block - * @param add - true to add a block, false to remove - * @return limit amount if over limit, or -1 if no limitation - */ - private int process(Block b, boolean add) { - return process(b, b.getBlockData(), add); - } - - /** - * Check if a block can be placed or needs to be removed based on limits. - * This variant allows specifying block data different from the block's current data, - * which is needed for block state transitions (e.g., copper oxidation or waxing). - * - * @param b - block (used for location and world) - * @param blockData - block data to use for material checks - * @param add - true to add a block, false to remove - * @return limit amount if over limit, or -1 if no limitation - */ - private int process(Block b, BlockData blockData, boolean add) { - if (DO_NOT_COUNT.contains(fixMaterial(blockData)) || !addon.inGameModeWorld(b.getWorld())) { - return -1; - } - // Check if on island - return addon.getIslands().getIslandAt(b.getLocation()).map(i -> { - String id = i.getUniqueId(); - String gameMode = addon.getGameModeName(b.getWorld()); - if (gameMode.isEmpty()) { - // Invalid world - return -1; - } - // Ignore the center block - usually bedrock, but for AOneBlock it's the magic block - if (addon.getConfig().getBoolean("ignore-center-block", true) && i.getCenter().equals(b.getLocation())) { - return -1; - } - islandCountMap.putIfAbsent(id, new IslandBlockCount(id, gameMode)); - if (add) { - // Check limit - int limit = checkLimit(b.getWorld(), fixMaterial(blockData), id); - if (limit > -1) { - return limit; - } - islandCountMap.get(id).add(fixMaterial(blockData)); - } else { - if (islandCountMap.containsKey(id)) { - islandCountMap.get(id).remove(fixMaterial(blockData)); - } - } - updateSaveMap(id); - return -1; - }).orElse(-1); - } - - /** - * Removed a block from any island limit count - * @param b - block to remove - */ - public void removeBlock(Block b) { - // Get island - addon.getIslands().getIslandAt(b.getLocation()).ifPresent(i -> { - String id = i.getUniqueId(); - String gameMode = addon.getGameModeName(b.getWorld()); - if (gameMode.isEmpty()) { - // Invalid world - return; - } - islandCountMap.computeIfAbsent(id, k -> new IslandBlockCount(id, gameMode)).remove(fixMaterial(b.getBlockData())); - updateSaveMap(id); - }); - } - private void updateSaveMap(String id) { - saveMap.putIfAbsent(id, 0); - if (saveMap.merge(id, 1, Integer::sum) > CHANGE_LIMIT) { - handler.saveObjectAsync(islandCountMap.get(id)); - saveMap.remove(id); - } - } - - - /** - * Check if this material is at its limit for world on this island - * - * @param w - world - * @param m - material - * @param id - island id - * @return limit amount if at limit or -1 if no limit - */ - private int checkLimit(World w, NamespacedKey m, String id) { - // Check island limits - IslandBlockCount ibc = islandCountMap.get(id); - if (ibc.isBlockLimited(m)) { - return ibc.isAtLimit(m) ? ibc.getBlockLimit(m) + ibc.getBlockLimitOffset(m) : -1; - } - // Check specific world limits - if (worldLimitMap.containsKey(w) && worldLimitMap.get(w).containsKey(m)) { - // Material is overridden in world - return ibc.isAtLimit(m, worldLimitMap.get(w).get(m)) ? worldLimitMap.get(w).get(m) + ibc.getBlockLimitOffset(m) : -1; - } - // Check default limit map - if (defaultLimitMap.containsKey(m) && ibc.isAtLimit(m, defaultLimitMap.get(m))) { - return defaultLimitMap.get(m) + ibc.getBlockLimitOffset(m); - } - // No limit - return -1; - } - - /** - * Gets an aggregate map of the limits for this island - * - * @param w - world - * @param id - island id - * @return map of limits for materials - */ - public Map getMaterialLimits(World w, String id) { - // Merge limits - Map result = new HashMap<>(); - // Default - result.putAll(defaultLimitMap); - // World - if (worldLimitMap.containsKey(w)) { - result.putAll(worldLimitMap.get(w)); - } - // Island - if (islandCountMap.containsKey(id)) { - IslandBlockCount islandBlockCount = islandCountMap.get(id); - result.putAll(islandBlockCount.getBlockLimits()); - - // Add offsets to the every limit. - islandBlockCount.getBlockLimitsOffset().forEach((material, offset) -> - result.put(material, result.getOrDefault(material, 0) + offset)); - } - return result; - } - - /** - * Removes island from the database - * - * @param e - island delete event - */ - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onIslandDelete(IslandDeleteEvent e) { - islandCountMap.remove(e.getIsland().getUniqueId()); - saveMap.remove(e.getIsland().getUniqueId()); - if (handler.objectExists(e.getIsland().getUniqueId())) { - handler.deleteID(e.getIsland().getUniqueId()); - } - } - - /** - * Set the island block count values - * - * @param islandId - island unique id - * @param ibc - island block count - */ - public void setIsland(String islandId, IslandBlockCount ibc) { - islandCountMap.put(islandId, ibc); - handler.saveObjectAsync(ibc); - } - - /** - * Get the island block count - * - * @param islandId - island unique id - * @return island block count or null if there is none yet - */ - @Nullable - public IslandBlockCount getIsland(String islandId) { - return islandCountMap.get(islandId); - } - - /** - * Get the island block count for island and make one if it does not exist - * @param island island - * @return island block count - */ - @NonNull - public IslandBlockCount getIsland(Island island) { - return islandCountMap.computeIfAbsent(island.getUniqueId(), k -> new IslandBlockCount(k, island.getGameMode())); - } - -} +package world.bentobox.limits.listeners; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.Tag; +import org.bukkit.World; +import org.bukkit.World.Environment; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.TechnicalPiston; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.event.Cancellable; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockFadeEvent; +import org.bukkit.event.block.BlockFormEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockGrowEvent; +import org.bukkit.event.block.BlockMultiPlaceEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.block.BlockSpreadEvent; +import org.bukkit.event.block.EntityBlockFormEvent; +import org.bukkit.event.block.LeavesDecayEvent; +import org.bukkit.event.entity.EntityChangeBlockEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import com.google.common.base.Enums; + +import world.bentobox.bentobox.api.events.island.IslandDeleteEvent; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.Database; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.util.Util; +import world.bentobox.limits.Limits; +import world.bentobox.limits.Settings; +import world.bentobox.limits.objects.IslandBlockCount; + +/** + * @author tastybento + */ +public class BlockLimitsListener implements Listener { + + /** Blocks that are not counted */ + private static final List DO_NOT_COUNT = List.of(Material.LAVA.getKey(), Material.WATER.getKey(), + Material.AIR.getKey(), Material.FIRE.getKey(), Material.END_PORTAL.getKey(), + Material.NETHER_PORTAL.getKey()); + private static final List STACKABLE = List.of(Material.SUGAR_CANE.getKey(), Material.BAMBOO.getKey()); + + /* + * Materials added in Minecraft 1.21.9 ("Copper Age"). Resolved by name so the + * addon links and runs on older servers (< 1.21.9) where these constants are + * absent, instead of throwing NoSuchFieldError. Absent Optional == not on this server. + */ + @Nullable + private static final Material COPPER_WALL_TORCH = Enums.getIfPresent(Material.class, "COPPER_WALL_TORCH").orNull(); + @Nullable + private static final Material COPPER_TORCH = Enums.getIfPresent(Material.class, "COPPER_TORCH").orNull(); + @Nullable + private static final Material COPPER_CHEST = Enums.getIfPresent(Material.class, "COPPER_CHEST").orNull(); + /** All weathered/waxed copper chest variants that normalise to {@link #COPPER_CHEST}. */ + private static final List COPPER_CHEST_VARIANTS = List.of("EXPOSED_COPPER_CHEST", "WEATHERED_COPPER_CHEST", + "OXIDIZED_COPPER_CHEST", "WAXED_COPPER_CHEST", "WAXED_EXPOSED_COPPER_CHEST", "WAXED_WEATHERED_COPPER_CHEST", + "WAXED_OXIDIZED_COPPER_CHEST").stream().map(name -> Enums.getIfPresent(Material.class, name).orNull()) + .filter(Objects::nonNull).toList(); + + /** + * Variant block materials mapped to the canonical material we count them as. + * Pistons are handled separately because the canonical form depends on block data. + */ + private static final Map VARIANT_MAP = new EnumMap<>(Material.class); + static { + VARIANT_MAP.put(Material.CHIPPED_ANVIL, Material.ANVIL); + VARIANT_MAP.put(Material.DAMAGED_ANVIL, Material.ANVIL); + VARIANT_MAP.put(Material.REDSTONE_WALL_TORCH, Material.REDSTONE_TORCH); + VARIANT_MAP.put(Material.WALL_TORCH, Material.TORCH); + VARIANT_MAP.put(Material.ZOMBIE_WALL_HEAD, Material.ZOMBIE_HEAD); + VARIANT_MAP.put(Material.CREEPER_WALL_HEAD, Material.CREEPER_HEAD); + VARIANT_MAP.put(Material.PLAYER_WALL_HEAD, Material.PLAYER_HEAD); + VARIANT_MAP.put(Material.DRAGON_WALL_HEAD, Material.DRAGON_HEAD); + VARIANT_MAP.put(Material.BAMBOO_SAPLING, Material.BAMBOO); + // 1.21.9 materials: only mapped when present on this server + if (COPPER_WALL_TORCH != null && COPPER_TORCH != null) { + VARIANT_MAP.put(COPPER_WALL_TORCH, COPPER_TORCH); + } + if (COPPER_CHEST != null) { + COPPER_CHEST_VARIANTS.forEach(variant -> VARIANT_MAP.put(variant, COPPER_CHEST)); + } + } + + /** Save every 10 blocks of change */ + private static final Integer CHANGE_LIMIT = 9; + private final Limits addon; + private final Map islandCountMap = new HashMap<>(); + private final Map saveMap = new HashMap<>(); + private final Database handler; + private final Map> worldLimitMap = new HashMap<>(); + /** + * Per-environment default limits. The "blocklimits" section seeds every env; + * "blocklimits-nether" / "blocklimits-end" sections override one env each. + */ + private final Map> envDefaultLimitMap = new EnumMap<>(Environment.class); + + public BlockLimitsListener(Limits addon) { + this.addon = addon; + for (Environment env : Settings.ENVIRONMENTS) { + envDefaultLimitMap.put(env, new HashMap<>()); + } + handler = new Database<>(addon, IslandBlockCount.class); + List toBeDeleted = new ArrayList<>(); + handler.loadObjects().forEach(ibc -> { + if (addon.isCoveredGameMode(ibc.getGameMode())) { + // Strip uncountable types from every env's count map + ibc.getAllBlockCounts().values().forEach(m -> m.keySet().removeIf(DO_NOT_COUNT::contains)); + islandCountMap.put(ibc.getUniqueId(), ibc); + } else { + toBeDeleted.add(ibc.getUniqueId()); + } + }); + toBeDeleted.forEach(handler::deleteID); + loadAllLimits(); + } + + /** + * Loads the default and world-specific limits + */ + private void loadAllLimits() { + // Pass 1: "blocklimits" applies to every env as the global default + if (addon.getConfig().isConfigurationSection("blocklimits")) { + ConfigurationSection limitConfig = addon.getConfig().getConfigurationSection("blocklimits"); + Map base = loadLimits(Objects.requireNonNull(limitConfig)); + for (Environment env : Settings.ENVIRONMENTS) { + envDefaultLimitMap.get(env).putAll(base); + } + addon.log("Loading default block limits"); + } + // Pass 2: env-specific sections override the base for that env + loadEnvSection("blocklimits-nether", Environment.NETHER); + loadEnvSection("blocklimits-end", Environment.THE_END); + + // Per-world named overrides + if (addon.getConfig().isConfigurationSection("worlds")) { + ConfigurationSection worlds = addon.getConfig().getConfigurationSection("worlds"); + for (String worldName : Objects.requireNonNull(worlds).getKeys(false)) { + World world = Bukkit.getWorld(worldName); + if (world != null && addon.inGameModeWorld(world)) { + addon.log("Loading limits for " + world.getName()); + ConfigurationSection matsConfig = worlds.getConfigurationSection(worldName); + worldLimitMap.put(world, loadLimits(Objects.requireNonNull(matsConfig))); + } + } + } + } + + private void loadEnvSection(String section, Environment env) { + if (!addon.getConfig().isConfigurationSection(section)) return; + ConfigurationSection cs = addon.getConfig().getConfigurationSection(section); + addon.log("Loading " + env + " block limit overrides from " + section); + envDefaultLimitMap.get(env).putAll(loadLimits(Objects.requireNonNull(cs))); + } + + /** + * Loads limit map from configuration section + */ + private Map loadLimits(ConfigurationSection cs) { + Map limits = new HashMap<>(); + for (String key : cs.getKeys(false)) { + NamespacedKey nsKey = parseConfigKey(key); + if (nsKey != null) { + registerLimit(limits, nsKey, key, cs.getInt(key)); + } + } + return limits; + } + + /** Parse a config key into a NamespacedKey, or null if it is an invalid namespaced key. */ + private NamespacedKey parseConfigKey(String key) { + if (key.contains(":")) { + NamespacedKey nsKey = NamespacedKey.fromString(key.toLowerCase(Locale.ROOT)); + if (nsKey == null) { + Bukkit.getLogger().warning(() -> "Invalid namespaced key in config, skipping: " + key); + } + return nsKey; + } + return new NamespacedKey(NamespacedKey.MINECRAFT, key.toLowerCase(Locale.ROOT)); + } + + /** Resolve a key to a material or block tag and store its limit, warning on any mismatch. */ + private void registerLimit(Map limits, NamespacedKey nsKey, String key, int limit) { + Material mat = Registry.MATERIAL.get(nsKey); + if (mat != null) { + if (!mat.isBlock()) { + Bukkit.getLogger().warning(() -> "Non-block material in block limits config: " + key); + } else if (DO_NOT_COUNT.contains(mat.getKey())) { + Bukkit.getLogger().warning(() -> "Uncountable material in block limits config: " + key); + } else { + limits.put(mat.getKey(), limit); + } + return; + } + Tag tag = Bukkit.getTag("blocks", nsKey, Material.class); + if (tag != null) { + limits.put(tag.getKey(), limit); + return; + } + Bukkit.getLogger().warning(() -> "Unknown material or tag in config: " + key); + } + + /** Save the count database completely */ + public void save() { + islandCountMap.values().stream().filter(IslandBlockCount::isChanged).forEach(handler::saveObjectAsync); + } + + /** Resolve the env, normalising any non-standard values to NORMAL. */ + private Environment envOf(World w) { + Environment env = w.getEnvironment(); + return Settings.ENVIRONMENTS.contains(env) ? env : Environment.NORMAL; + } + + // Player-related events + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(BlockPlaceEvent e) { + // BlockMultiPlaceEvent is a subclass of BlockPlaceEvent and shares its HandlerList, + // so it is delivered here too. Let the dedicated handler count it once (see #86). + if (e instanceof BlockMultiPlaceEvent) { + return; + } + notify(e, User.getInstance(e.getPlayer()), process(e.getBlock(), true), e.getBlock().getType()); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(BlockBreakEvent e) { + if (e.getBlock().hasMetadata("blockbreakevent-ignore")) { + return; + } + handleBreak(e.getBlock()); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onTurtleEggBreak(PlayerInteractEvent e) { + if (e.getAction().equals(Action.PHYSICAL) && e.getClickedBlock() != null + && e.getClickedBlock().getType().equals(Material.TURTLE_EGG)) { + handleBreak(e.getClickedBlock()); + } + } + + private void handleBreak(Block b) { + if (!addon.inGameModeWorld(b.getWorld())) { + return; + } + Material mat = b.getType(); + if (STACKABLE.contains(b.getType().getKey())) { + Block block = b; + while (block.getRelative(BlockFace.UP).getType().equals(mat) + && block.getY() < b.getWorld().getMaxHeight()) { + block = block.getRelative(BlockFace.UP); + process(block, false); + } + } + process(b, false); + if (b.getRelative(BlockFace.UP).getType() == Material.REDSTONE_WIRE + || b.getRelative(BlockFace.UP).getType() == Material.REPEATER + || b.getRelative(BlockFace.UP).getType() == Material.COMPARATOR + || b.getRelative(BlockFace.UP).getType() == Material.REDSTONE_TORCH) { + process(b.getRelative(BlockFace.UP), false); + } + if (b.getRelative(BlockFace.EAST).getType() == Material.REDSTONE_WALL_TORCH) { + process(b.getRelative(BlockFace.EAST), false); + } + if (b.getRelative(BlockFace.WEST).getType() == Material.REDSTONE_WALL_TORCH) { + process(b.getRelative(BlockFace.WEST), false); + } + if (b.getRelative(BlockFace.SOUTH).getType() == Material.REDSTONE_WALL_TORCH) { + process(b.getRelative(BlockFace.SOUTH), false); + } + if (b.getRelative(BlockFace.NORTH).getType() == Material.REDSTONE_WALL_TORCH) { + process(b.getRelative(BlockFace.NORTH), false); + } + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(BlockMultiPlaceEvent e) { + notify(e, User.getInstance(e.getPlayer()), process(e.getBlock(), true), e.getBlock().getType()); + } + + private void notify(Cancellable e, User user, int limit, Material m) { + if (limit > -1) { + user.notify("block-limits.hit-limit", + "[material]", Util.prettifyText(m.toString()), + TextVariables.NUMBER, String.valueOf(limit)); + e.setCancelled(true); + } + } + + // Non-player events + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(BlockBurnEvent e) { + process(e.getBlock(), false); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(BlockExplodeEvent e) { + e.blockList().forEach(b -> process(b, false)); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(BlockFadeEvent e) { + process(e.getBlock(), false); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(BlockFormEvent e) { + if (e instanceof EntityBlockFormEvent || e instanceof BlockSpreadEvent) { + return; + } + process(e.getBlock(), false); + if (process(e.getBlock(), e.getNewState().getBlockData(), true) > -1) { + e.setCancelled(true); + process(e.getBlock(), true); + } + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(BlockSpreadEvent e) { + process(e.getBlock(), false); + if (process(e.getBlock(), e.getNewState().getBlockData(), true) > -1) { + e.setCancelled(true); + process(e.getBlock(), true); + } + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(EntityBlockFormEvent e) { + process(e.getBlock(), false); + if (process(e.getBlock(), e.getNewState().getBlockData(), true) > -1) { + e.setCancelled(true); + process(e.getBlock(), true); + } + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(BlockGrowEvent e) { + if (process(e.getNewState().getBlock(), true) > -1) { + e.setCancelled(true); + e.getBlock().getWorld().getBlockAt(e.getBlock().getLocation()).setBlockData(e.getBlock().getBlockData()); + } else { + process(e.getBlock(), false); + } + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(LeavesDecayEvent e) { + process(e.getBlock(), false); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(EntityExplodeEvent e) { + e.blockList().forEach(b -> process(b, false)); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(EntityChangeBlockEvent e) { + if (e.getTo() != Material.AIR) { + int limit = process(e.getBlock(), e.getBlockData(), true); + if (limit > -1) { + e.setCancelled(true); + return; + } + } + process(e.getBlock(), false); + if (!e.isCancelled() && e.getBlock().getType().equals(Material.FARMLAND)) { + process(e.getBlock().getRelative(BlockFace.UP), false); + } + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onBlock(BlockFromToEvent e) { + if (e.getBlock().isLiquid() + && (e.getToBlock().getType() == Material.REDSTONE_WIRE + || e.getToBlock().getType() == Material.REPEATER + || e.getToBlock().getType() == Material.COMPARATOR + || e.getToBlock().getType() == Material.REDSTONE_TORCH + || e.getToBlock().getType() == Material.REDSTONE_WALL_TORCH)) { + process(e.getToBlock(), false); + } + } + + /** + * Map variant materials to their canonical form. + */ + public NamespacedKey fixMaterial(BlockData b) { + Material mat = b.getMaterial(); + if (mat == Material.PISTON_HEAD || mat == Material.MOVING_PISTON) { + TechnicalPiston tp = (TechnicalPiston) b; + return (tp.getType() == TechnicalPiston.Type.NORMAL ? Material.PISTON : Material.STICKY_PISTON).getKey(); + } + return VARIANT_MAP.getOrDefault(mat, mat).getKey(); + } + + private int process(Block b, boolean add) { + return process(b, b.getBlockData(), add); + } + + /** + * Check if a block can be placed or needs to be removed based on limits. + * + * @return limit amount if over limit, or -1 if no limitation + */ + private int process(Block b, BlockData blockData, boolean add) { + if (DO_NOT_COUNT.contains(fixMaterial(blockData)) || !addon.inGameModeWorld(b.getWorld())) { + return -1; + } + Environment env = envOf(b.getWorld()); + return addon.getIslands().getIslandAt(b.getLocation()).map(i -> { + String id = i.getUniqueId(); + String gameMode = addon.getGameModeName(b.getWorld()); + if (gameMode.isEmpty()) { + return -1; + } + if (addon.getConfig().getBoolean("ignore-center-block", true) + && i.getCenter().equals(b.getLocation())) { + return -1; + } + islandCountMap.putIfAbsent(id, new IslandBlockCount(id, gameMode)); + NamespacedKey key = fixMaterial(blockData); + if (add) { + int limit = checkLimit(b.getWorld(), env, key, id); + if (limit > -1) { + return limit; + } + islandCountMap.get(id).add(env, key); + } else { + islandCountMap.get(id).remove(env, key); + } + updateSaveMap(id); + return -1; + }).orElse(-1); + } + + /** + * Remove a block from any island limit count + */ + public void removeBlock(Block b) { + addon.getIslands().getIslandAt(b.getLocation()).ifPresent(i -> { + String id = i.getUniqueId(); + String gameMode = addon.getGameModeName(b.getWorld()); + if (gameMode.isEmpty()) return; + Environment env = envOf(b.getWorld()); + islandCountMap.computeIfAbsent(id, k -> new IslandBlockCount(id, gameMode)) + .remove(env, fixMaterial(b.getBlockData())); + updateSaveMap(id); + }); + } + + private void updateSaveMap(String id) { + saveMap.putIfAbsent(id, 0); + if (saveMap.merge(id, 1, Integer::sum) > CHANGE_LIMIT) { + handler.saveObjectAsync(islandCountMap.get(id)); + saveMap.remove(id); + } + } + + /** + * Resolve the active limit for this material in this world for this island. + * Priority: island-env limit, world-named limit, env-default, none. + * + * @return limit if at or over, or -1 if no limit + */ + private int checkLimit(World w, Environment env, NamespacedKey m, String id) { + IslandBlockCount ibc = islandCountMap.get(id); + if (ibc.isBlockLimited(env, m)) { + int offset = ibc.getBlockLimitOffset(env, m); + return ibc.isAtLimit(env, m) ? ibc.getBlockLimit(env, m) + offset : -1; + } + Map worldMap = worldLimitMap.get(w); + if (worldMap != null && worldMap.containsKey(m)) { + int worldLimit = worldMap.get(m); + int offset = ibc.getBlockLimitOffset(env, m); + return ibc.isAtLimit(env, m, worldLimit) ? worldLimit + offset : -1; + } + Map envDefaults = envDefaultLimitMap.get(env); + if (envDefaults != null && envDefaults.containsKey(m)) { + int envLimit = envDefaults.get(m); + int offset = ibc.getBlockLimitOffset(env, m); + if (ibc.isAtLimit(env, m, envLimit)) { + return envLimit + offset; + } + } + return -1; + } + + /** + * Aggregate map of the resolved limits for this island in the given world. + */ + public Map getMaterialLimits(World w, String id) { + Environment env = envOf(w); + Map result = new HashMap<>(); + Map envDefaults = envDefaultLimitMap.get(env); + if (envDefaults != null) { + result.putAll(envDefaults); + } + Map worldMap = worldLimitMap.get(w); + if (worldMap != null) { + result.putAll(worldMap); + } + IslandBlockCount ibc = islandCountMap.get(id); + if (ibc != null) { + result.putAll(ibc.getBlockLimits(env)); + ibc.getBlockLimitsOffset(env).forEach( + (material, offset) -> result.put(material, result.getOrDefault(material, 0) + offset)); + } + return result; + } + + /** + * Per-environment map of the env-default limits, used for tests and external introspection. + */ + public Map> getEnvDefaultLimitMap() { + return envDefaultLimitMap; + } + + public Map> getWorldLimitMap() { + return worldLimitMap; + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onIslandDelete(IslandDeleteEvent e) { + islandCountMap.remove(e.getIsland().getUniqueId()); + saveMap.remove(e.getIsland().getUniqueId()); + if (handler.objectExists(e.getIsland().getUniqueId())) { + handler.deleteID(e.getIsland().getUniqueId()); + } + } + + public void setIsland(String islandId, IslandBlockCount ibc) { + islandCountMap.put(islandId, ibc); + handler.saveObjectAsync(ibc); + } + + @Nullable + public IslandBlockCount getIsland(String islandId) { + return islandCountMap.get(islandId); + } + + @NonNull + public IslandBlockCount getIsland(Island island) { + return islandCountMap.computeIfAbsent(island.getUniqueId(), + k -> new IslandBlockCount(k, island.getGameMode())); + } +} diff --git a/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java b/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java index 145cb00..25f02f0 100644 --- a/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java +++ b/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java @@ -1,6 +1,7 @@ package world.bentobox.limits.listeners; import org.bukkit.*; +import org.bukkit.World.Environment; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.entity.*; @@ -11,8 +12,14 @@ import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason; import org.bukkit.event.entity.EntityBreedEvent; +import org.bukkit.event.entity.EntityPortalEvent; +import org.bukkit.event.entity.EntityRemoveEvent; +import org.bukkit.event.block.Action; import org.bukkit.event.hanging.HangingPlaceEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.vehicle.VehicleCreateEvent; +import org.bukkit.inventory.ItemStack; import org.eclipse.jdt.annotation.Nullable; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.localization.TextVariables; @@ -21,6 +28,7 @@ import world.bentobox.bentobox.util.Util; import world.bentobox.limits.EntityGroup; import world.bentobox.limits.Limits; +import world.bentobox.limits.Settings; import world.bentobox.limits.objects.IslandBlockCount; import java.util.*; @@ -28,130 +36,116 @@ /** * Listener for entity limit events and logic. + * + *

Counts are stored persistently per-island per-environment in {@link IslandBlockCount} + * (see {@code envEntityCounts}). This listener increments on successful spawn and + * decrements on {@link EntityRemoveEvent} (excluding chunk unload), so counts remain + * accurate even when nether/end chunks are unloaded — fixes + * issue #43. */ public class EntityLimitListener implements Listener { - /** - * Permission node for bypassing limits. - */ + /** Permission node for bypassing limits. */ private static final String MOD_BYPASS = "mod.bypass"; - /** - * Reference to the Limits addon. - */ + /** Locale reference for the entity limit notification. */ + private static final String ENTITY_LIMIT_HIT = "entity-limits.hit-limit"; + /** Notification placeholder for the entity name. */ + private static final String ENTITY_PLACEHOLDER = "[entity]"; private final Limits addon; - /** - * List of entity UUIDs that have just spawned to prevent double-processing. - */ + /** Entity UUIDs that have just spawned to prevent double-processing. */ private final List justSpawned = new ArrayList<>(); - /** - * Cardinal directions used for block structure detection. - */ - private static final List CARDINALS = List.of(BlockFace.UP, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST, BlockFace.DOWN); + /** Entity UUIDs that are currently portaling to prevent double-decrement on cross-world removal. */ + private final List justPortaled = new ArrayList<>(); + /** Maps entity UUID to island ID so decrement works even when the entity dies off-island. */ + private final Map entityIslandMap = new HashMap<>(); + /** Cardinal directions used for block structure detection. */ + private static final List CARDINALS = List.of(BlockFace.UP, BlockFace.NORTH, BlockFace.SOUTH, + BlockFace.EAST, BlockFace.WEST, BlockFace.DOWN); + /** One block face per horizontal axis, used to find golem arms regardless of facing. */ + private static final List HORIZONTAL = List.of(BlockFace.NORTH, BlockFace.EAST); - /** - * Constructs the EntityLimitListener. - * - * @param addon Limits addon instance - */ public EntityLimitListener(Limits addon) { this.addon = addon; } - /** - * Handles minecart placement and checks entity limits. - * - * @param vehicleCreateEvent VehicleCreateEvent instance - */ + private static Environment envOf(World w) { + Environment env = w.getEnvironment(); + return Settings.ENVIRONMENTS.contains(env) ? env : Environment.NORMAL; + } + + /* ========================================================================= + * Limit-check handlers (LOW priority — cancel if at limit) + * ========================================================================= */ + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) public void onMinecart(VehicleCreateEvent vehicleCreateEvent) { - // Return if not in a known world - if (!addon.inGameModeWorld(vehicleCreateEvent.getVehicle().getWorld())) { - return; - } - // Debounce + if (!addon.inGameModeWorld(vehicleCreateEvent.getVehicle().getWorld())) return; if (justSpawned.contains(vehicleCreateEvent.getVehicle().getUniqueId())) { justSpawned.remove(vehicleCreateEvent.getVehicle().getUniqueId()); return; } - // Check island addon.getIslands().getProtectedIslandAt(vehicleCreateEvent.getVehicle().getLocation()) - // Ignore spawn .filter(i -> !i.isSpawn()) .ifPresent(island -> { - // Check if the player is at the limit AtLimitResult res = atLimit(island, vehicleCreateEvent.getVehicle()); if (res.hit()) { vehicleCreateEvent.setCancelled(true); - this.tellPlayers(vehicleCreateEvent.getVehicle().getLocation(), vehicleCreateEvent.getVehicle(), SpawnReason.MOUNT, res); + this.tellPlayers(vehicleCreateEvent.getVehicle().getLocation(), + vehicleCreateEvent.getVehicle(), SpawnReason.MOUNT, res); } }); } - /** - * Handles entity breeding and checks entity limits. - * - * @param entityBreedEvent EntityBreedEvent instance - */ @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) public void onBreed(final EntityBreedEvent entityBreedEvent) { if (addon.inGameModeWorld(entityBreedEvent.getEntity().getWorld()) && entityBreedEvent.getBreeder() != null && (entityBreedEvent.getBreeder() instanceof Player player) - && !(player.isOp() || player.hasPermission(addon.getPlugin().getIWM().getPermissionPrefix(entityBreedEvent.getEntity().getWorld()) + MOD_BYPASS)) + && !(player.isOp() || player.hasPermission(addon.getPlugin().getIWM() + .getPermissionPrefix(entityBreedEvent.getEntity().getWorld()) + MOD_BYPASS)) && !checkLimit(entityBreedEvent, entityBreedEvent.getEntity(), SpawnReason.BREEDING, false) - && entityBreedEvent.getFather() instanceof Breedable father && entityBreedEvent.getMother() instanceof Breedable mother) { + && entityBreedEvent.getFather() instanceof Breedable father + && entityBreedEvent.getMother() instanceof Breedable mother) { father.setBreed(false); mother.setBreed(false); } } - /** - * Handles creature spawning and checks entity limits. - * - * @param creatureSpawnEvent CreatureSpawnEvent instance - */ @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) public void onCreatureSpawn(final CreatureSpawnEvent creatureSpawnEvent) { - // Return if not in a known world - if (!addon.inGameModeWorld(creatureSpawnEvent.getLocation().getWorld())) { - return; - } + if (!addon.inGameModeWorld(creatureSpawnEvent.getLocation().getWorld())) return; if (justSpawned.contains(creatureSpawnEvent.getEntity().getUniqueId())) { justSpawned.remove(creatureSpawnEvent.getEntity().getUniqueId()); return; } - if (creatureSpawnEvent.getSpawnReason().equals(SpawnReason.SHOULDER_ENTITY) || (!(creatureSpawnEvent.getEntity() instanceof Villager) && creatureSpawnEvent.getSpawnReason().equals(SpawnReason.BREEDING))) { - // Special case - do nothing - jumping around spawns parrots as they drop off player's shoulder - // Ignore breeding because it's handled in the EntityBreedEvent listener + if (creatureSpawnEvent.getSpawnReason().equals(SpawnReason.SHOULDER_ENTITY) + || (!(creatureSpawnEvent.getEntity() instanceof Villager) + && creatureSpawnEvent.getSpawnReason().equals(SpawnReason.BREEDING))) { return; } - // Some checks can be done async, some not - if (creatureSpawnEvent.getSpawnReason().equals(SpawnReason.BUILD_SNOWMAN) || creatureSpawnEvent.getSpawnReason().equals(SpawnReason.BUILD_IRONGOLEM) ) { - checkLimit(creatureSpawnEvent, creatureSpawnEvent.getEntity(), creatureSpawnEvent.getSpawnReason(), addon.getSettings().isAsyncGolums()); + if (creatureSpawnEvent.getSpawnReason().equals(SpawnReason.BUILD_SNOWMAN) + || creatureSpawnEvent.getSpawnReason().equals(SpawnReason.BUILD_IRONGOLEM)) { + checkLimit(creatureSpawnEvent, creatureSpawnEvent.getEntity(), creatureSpawnEvent.getSpawnReason(), + addon.getSettings().isAsyncGolums()); } else { - // Check limit sync - checkLimit(creatureSpawnEvent, creatureSpawnEvent.getEntity(), creatureSpawnEvent.getSpawnReason(), false); + checkLimit(creatureSpawnEvent, creatureSpawnEvent.getEntity(), creatureSpawnEvent.getSpawnReason(), + false); } - } - /** - * handles paintings and item frames - * - * @param hangingPlaceEvent - event - */ - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) public void onBlock(HangingPlaceEvent hangingPlaceEvent) { - if (!addon.inGameModeWorld(hangingPlaceEvent.getBlock().getWorld())) { - return; - } + if (!addon.inGameModeWorld(hangingPlaceEvent.getBlock().getWorld())) return; Player player = hangingPlaceEvent.getPlayer(); if (player == null) return; addon.getIslands().getIslandAt(hangingPlaceEvent.getEntity().getLocation()).ifPresent(island -> { - boolean bypass = Objects.requireNonNull(player).isOp() || player.hasPermission(addon.getPlugin().getIWM().getPermissionPrefix(hangingPlaceEvent.getEntity().getWorld()) + MOD_BYPASS); - // Check if entity can be hung - AtLimitResult res; - if (!bypass && !island.isSpawn() && (res = atLimit(island, hangingPlaceEvent.getEntity())).hit()) { - // Not allowed + boolean bypass = Objects.requireNonNull(player).isOp() || player.hasPermission( + addon.getPlugin().getIWM().getPermissionPrefix(hangingPlaceEvent.getEntity().getWorld()) + + MOD_BYPASS); + if (bypass || island.isSpawn()) { + return; + } + AtLimitResult res = atLimit(island, hangingPlaceEvent.getEntity()); + if (res.hit()) { hangingPlaceEvent.setCancelled(true); if (res.getTypelimit() != null) { User.getInstance(player).notify("block-limits.hit-limit", "[material]", @@ -159,7 +153,11 @@ public void onBlock(HangingPlaceEvent hangingPlaceEvent) { TextVariables.NUMBER, String.valueOf(res.getTypelimit().getValue())); } else { User.getInstance(player).notify("block-limits.hit-limit", "[material]", - res.getGrouplimit().getKey().getName() + " (" + res.getGrouplimit().getKey().getTypes().stream().map(x -> Util.prettifyText(x.toString())).collect(Collectors.joining(", ")) + ")", + res.getGrouplimit().getKey().getName() + " (" + + res.getGrouplimit().getKey().getTypes().stream() + .map(x -> Util.prettifyText(x.toString())) + .collect(Collectors.joining(", ")) + + ")", TextVariables.NUMBER, String.valueOf(res.getGrouplimit().getValue())); } } @@ -167,15 +165,198 @@ public void onBlock(HangingPlaceEvent hangingPlaceEvent) { } /** - * Checks if a creature is allowed to spawn or not. + * Deny spawn-egg use the moment a player would exceed an entity limit, before the egg is + * consumed. The {@link CreatureSpawnEvent} path already cancels the over-limit spawn, but by + * then the egg item has been used up; cancelling the interaction here keeps the egg (#134). + */ + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onSpawnEggUseOnBlock(PlayerInteractEvent e) { + if (e.getAction() != Action.RIGHT_CLICK_BLOCK || e.getClickedBlock() == null) { + return; + } + EntityType type = spawnEggType(e.getItem()); + if (type != null) { + checkSpawnEggLimit(e, e.getPlayer(), e.getClickedBlock().getLocation(), type); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onSpawnEggUseOnEntity(PlayerInteractEntityEvent e) { + // PlayerInteractEntityEvent.getHand() is never null (always HAND or OFF_HAND) + EntityType type = spawnEggType(e.getPlayer().getInventory().getItem(e.getHand())); + if (type != null) { + checkSpawnEggLimit(e, e.getPlayer(), e.getRightClicked().getLocation(), type); + } + } + + private void checkSpawnEggLimit(Cancellable event, Player player, Location location, EntityType type) { + if (!addon.inGameModeWorld(location.getWorld())) { + return; + } + addon.getIslands().getIslandAt(location).ifPresent(island -> { + boolean bypass = player.isOp() || player.hasPermission( + addon.getPlugin().getIWM().getPermissionPrefix(location.getWorld()) + MOD_BYPASS); + if (bypass || island.isSpawn()) { + return; + } + AtLimitResult res = atLimit(island, type, envOf(location.getWorld())); + if (res.hit()) { + event.setCancelled(true); + notifyEntityLimit(player, type, res); + } + }); + } + + /** + * Resolve the entity type a spawn-egg item would create, e.g. {@code PIG_SPAWN_EGG -> PIG}. * - * @param cancelableEvent Cancelable event instance - * @param livingEntity Entity being spawned - * @param spawnReason Reason for spawning - * @param runAsync Whether to run asynchronously - * @return true if allowed or async, false if not + * @return the entity type, or {@code null} if the item is not a recognised spawn egg */ - private boolean checkLimit(Cancellable cancelableEvent, LivingEntity livingEntity, SpawnReason spawnReason, boolean runAsync) { + private EntityType spawnEggType(ItemStack item) { + if (item == null) { + return null; + } + String name = item.getType().name(); + if (!name.endsWith("_SPAWN_EGG")) { + return null; + } + try { + return EntityType.valueOf(name.substring(0, name.length() - "_SPAWN_EGG".length())); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private void notifyEntityLimit(Player player, EntityType type, AtLimitResult res) { + if (res.getTypelimit() != null) { + User.getInstance(player).notify(ENTITY_LIMIT_HIT, ENTITY_PLACEHOLDER, + Util.prettifyText(type.toString()), TextVariables.NUMBER, + String.valueOf(res.getTypelimit().getValue())); + } else { + User.getInstance(player).notify(ENTITY_LIMIT_HIT, ENTITY_PLACEHOLDER, + res.getGrouplimit().getKey().getName() + " (" + + res.getGrouplimit().getKey().getTypes().stream() + .map(x -> Util.prettifyText(x.toString())) + .collect(Collectors.joining(", ")) + + ")", + TextVariables.NUMBER, String.valueOf(res.getGrouplimit().getValue())); + } + } + + /* ========================================================================= + * Increment handlers (MONITOR priority — count entities that actually spawned) + * ========================================================================= */ + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onCreatureSpawnTrack(final CreatureSpawnEvent e) { + trackSpawn(e.getEntity()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onVehicleCreateTrack(final VehicleCreateEvent e) { + trackSpawn(e.getVehicle()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onHangingPlaceTrack(final HangingPlaceEvent e) { + trackSpawn(e.getEntity()); + } + + private void trackSpawn(Entity entity) { + if (entity == null) return; + World w = entity.getWorld(); + if (!addon.inGameModeWorld(w)) return; + addon.getIslands().getIslandAt(entity.getLocation()) + .filter(island -> !island.isSpawn()) + .ifPresent(island -> { + IslandBlockCount ibc = addon.getBlockLimitListener().getIsland(island); + ibc.incrementEntity(envOf(w), entity.getType()); + entityIslandMap.put(entity.getUniqueId(), island.getUniqueId()); + }); + } + + /* ========================================================================= + * Decrement on permanent removal + * ========================================================================= */ + + @EventHandler(priority = EventPriority.MONITOR) + public void onEntityRemove(EntityRemoveEvent e) { + // Entities unloaded with their chunk are still alive — don't decrement. + if (e.getCause() == EntityRemoveEvent.Cause.UNLOAD) return; + Entity entity = e.getEntity(); + World w = entity.getWorld(); + if (!addon.inGameModeWorld(w)) return; + + // Entity is being portaled — count transfer was already handled by onEntityPortal. + if (justPortaled.remove(entity.getUniqueId())) return; + + String islandId = entityIslandMap.remove(entity.getUniqueId()); + if (islandId != null) { + IslandBlockCount ibc = addon.getBlockLimitListener().getIsland(islandId); + if (ibc != null) { + ibc.decrementEntity(envOf(w), entity.getType()); + } + return; + } + + addon.getIslands().getIslandAt(entity.getLocation()) + .filter(island -> !island.isSpawn()) + .ifPresent(island -> { + IslandBlockCount ibc = addon.getBlockLimitListener().getIsland(island.getUniqueId()); + if (ibc != null) { + ibc.decrementEntity(envOf(w), entity.getType()); + } + }); + } + + /* ========================================================================= + * Portal: move count between envs on same island + * ========================================================================= */ + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onEntityPortal(EntityPortalEvent e) { + if (e.getTo() == null || e.getTo().getWorld() == null) return; + Entity entity = e.getEntity(); + World fromWorld = entity.getWorld(); + World toWorld = e.getTo().getWorld(); + Environment fromEnv = envOf(fromWorld); + Environment toEnv = envOf(toWorld); + if (fromEnv == toEnv) return; + if (!addon.inGameModeWorld(fromWorld) && !addon.inGameModeWorld(toWorld)) return; + + // Prevent onEntityRemove from double-decrementing for the source-world removal. + justPortaled.add(entity.getUniqueId()); + + // Decrement at source if on a tracked island + if (addon.inGameModeWorld(fromWorld)) { + entityIslandMap.remove(entity.getUniqueId()); + addon.getIslands().getIslandAt(entity.getLocation()) + .filter(island -> !island.isSpawn()) + .ifPresent(island -> { + IslandBlockCount ibc = addon.getBlockLimitListener().getIsland(island.getUniqueId()); + if (ibc != null) { + ibc.decrementEntity(fromEnv, entity.getType()); + } + }); + } + // Increment at destination if on a tracked island + if (addon.inGameModeWorld(toWorld)) { + addon.getIslands().getIslandAt(e.getTo()) + .filter(island -> !island.isSpawn()) + .ifPresent(island -> { + IslandBlockCount ibc = addon.getBlockLimitListener().getIsland(island); + ibc.incrementEntity(toEnv, entity.getType()); + entityIslandMap.put(entity.getUniqueId(), island.getUniqueId()); + }); + } + } + + /* ========================================================================= + * Limit-checking core + * ========================================================================= */ + + private boolean checkLimit(Cancellable cancelableEvent, LivingEntity livingEntity, SpawnReason spawnReason, + boolean runAsync) { Location l = livingEntity.getLocation(); if (runAsync) { cancelableEvent.setCancelled(true); @@ -183,147 +364,116 @@ private boolean checkLimit(Cancellable cancelableEvent, LivingEntity livingEntit return processIsland(cancelableEvent, livingEntity, l, spawnReason, runAsync); } - /** - * Processes the island for entity limit checks. - * - * @param cancelableEvent Cancelable event instance - * @param livingEntity Entity being spawned - * @param location Location of entity - * @param spawnReason Reason for spawning - * @param runAsync Whether to run asynchronously - * @return true if allowed, false if not - */ - private boolean processIsland(Cancellable cancelableEvent, LivingEntity livingEntity, Location location, SpawnReason spawnReason, boolean runAsync) { - if (addon.getIslands().getIslandAt(livingEntity.getLocation()).isEmpty()) { - cancelableEvent.setCancelled(false); + private boolean processIsland(Cancellable cancelableEvent, LivingEntity livingEntity, Location location, + SpawnReason spawnReason, boolean runAsync) { + Optional optionalIsland = addon.getIslands().getIslandAt(livingEntity.getLocation()); + if (optionalIsland.isEmpty()) { + if (runAsync) { + cancelableEvent.setCancelled(false); + } return true; } - Island island = addon.getIslands().getIslandAt(livingEntity.getLocation()).get(); - // Check if creature is allowed to spawn or not + Island island = optionalIsland.get(); AtLimitResult res = atLimit(island, livingEntity); if (island.isSpawn() || !res.hit()) { - // Allowed if (runAsync) { - Bukkit.getScheduler().runTask(BentoBox.getInstance(), () -> preSpawn(livingEntity.getType(), spawnReason, location)); - } // else do nothing + Bukkit.getScheduler().runTask(BentoBox.getInstance(), + () -> preSpawn(livingEntity.getType(), spawnReason, location)); + } } else { if (runAsync) { livingEntity.remove(); } else { cancelableEvent.setCancelled(true); } - // If the reason is anything but because of a spawner then tell players within range tellPlayers(location, livingEntity, spawnReason, res); return false; } return true; } - /** - * Spawns an entity and handles special cases for golems, snowmen, and withers. - * - * @param entityType Type of entity to spawn - * @param spawnReason Reason for spawning - * @param location Location to spawn - */ private void preSpawn(EntityType entityType, SpawnReason spawnReason, Location location) { - // Check for entities that need cleanup switch (spawnReason) { case BUILD_IRONGOLEM -> detectIronGolem(location); case BUILD_SNOWMAN -> detectSnowman(location); - case BUILD_WITHER -> { // Not used right now - detectWither(location); - } + case BUILD_WITHER -> detectWither(location); default -> throw new IllegalArgumentException("Unexpected value: " + spawnReason); } Entity entity = location.getWorld().spawnEntity(location, entityType); justSpawned.add(entity.getUniqueId()); if (spawnReason == SpawnReason.BUILD_WITHER) { - // Create explosion location.getWorld().createExplosion(location, 7F, true, true, entity); } } - /** - * Detects and removes iron golem construction blocks. - * - * @param location Location of golem spawn - */ private void detectIronGolem(Location location) { - Block legs = location.getBlock(); - addon.getBlockLimitListener().removeBlock(legs); - legs.setType(Material.AIR); - for (BlockFace bf : CARDINALS) { - Block body = legs.getRelative(bf); - if (body.getType().equals(Material.IRON_BLOCK)) { - Block head = body.getRelative(bf); - if (isGolemHead(head) && eraseGolemIfArmsMatch(body, head, bf)) { - return; - } + // Anchor on the carved pumpkin: body and base are the two iron blocks below it, + // arms are two opposite iron blocks beside the body on a horizontal axis. + Block head = findPumpkinHead(location, Material.IRON_BLOCK); + if (head == null) { + return; + } + Block body = head.getRelative(BlockFace.DOWN); + Block base = body.getRelative(BlockFace.DOWN); + if (base.getType() != Material.IRON_BLOCK) { + return; + } + for (BlockFace bf : HORIZONTAL) { + Block arm1 = body.getRelative(bf); + Block arm2 = body.getRelative(bf.getOppositeFace()); + if (arm1.getType() == Material.IRON_BLOCK && arm2.getType() == Material.IRON_BLOCK) { + eraseBlocks(head, body, base, arm1, arm2); + return; } } } - private boolean isGolemHead(Block block) { - return block.getType() == Material.CARVED_PUMPKIN || block.getType() == Material.JACK_O_LANTERN; - } - - private boolean eraseGolemIfArmsMatch(Block body, Block head, BlockFace bf) { - for (BlockFace bf2 : CARDINALS) { - Block arm1 = body.getRelative(bf2); - Block arm2 = body.getRelative(bf2.getOppositeFace()); - if (arm1.getType() == Material.IRON_BLOCK && arm2.getType() == Material.IRON_BLOCK - && arm1.getRelative(bf.getOppositeFace()).isEmpty() - && arm2.getRelative(bf.getOppositeFace()).isEmpty()) { - addon.getBlockLimitListener().removeBlock(body); - addon.getBlockLimitListener().removeBlock(arm1); - addon.getBlockLimitListener().removeBlock(arm2); - addon.getBlockLimitListener().removeBlock(head); - body.setType(Material.AIR); - arm1.setType(Material.AIR); - arm2.setType(Material.AIR); - head.setType(Material.AIR); - return true; - } + private void detectSnowman(Location location) { + // Anchor on the carved pumpkin: the two snow blocks below it are the body and base. + Block head = findPumpkinHead(location, Material.SNOW_BLOCK); + if (head == null) { + return; } - return false; + Block body = head.getRelative(BlockFace.DOWN); + Block base = body.getRelative(BlockFace.DOWN); + if (base.getType() != Material.SNOW_BLOCK) { + return; + } + eraseBlocks(head, body, base); } /** - * Detects and removes snowman construction blocks. + * Locate the carved pumpkin / jack o'lantern that tops a freshly built golem or snowman. * - * @param location Location of snowman spawn + *

The {@link CreatureSpawnEvent} location for a built mob is not guaranteed to be a + * particular block of the structure, so we anchor on the unambiguous pumpkin rather than + * assuming the spawn block is the base. Scan the spawn block and the two blocks above it + * for a head that sits directly on top of the expected body material. + * + * @return the pumpkin block, or {@code null} if none is found */ - private void detectSnowman(Location location) { - Block legs = location.getBlock(); - // Erase legs - addon.getBlockLimitListener().removeBlock(legs); - legs.setType(Material.AIR); - // Look around for possible constructions - for (BlockFace bf : CARDINALS) { - Block body = legs.getRelative(bf); - if (body.getType().equals(Material.SNOW_BLOCK)) { - // Check for head - Block head = body.getRelative(bf); - if (head.getType() == Material.CARVED_PUMPKIN || head.getType() == Material.JACK_O_LANTERN) { - // Erase - addon.getBlockLimitListener().removeBlock(body); - addon.getBlockLimitListener().removeBlock(head); - - body.setType(Material.AIR); - head.setType(Material.AIR); - return; - } + private Block findPumpkinHead(Location location, Material bodyMaterial) { + Block b = location.getBlock(); + for (int i = 0; i < 3; i++) { + if (isGolemHead(b) && b.getRelative(BlockFace.DOWN).getType() == bodyMaterial) { + return b; } + b = b.getRelative(BlockFace.UP); } + return null; + } + private boolean isGolemHead(Block block) { + return block.getType() == Material.CARVED_PUMPKIN || block.getType() == Material.JACK_O_LANTERN; + } + + private void eraseBlocks(Block... blocks) { + for (Block b : blocks) { + addon.getBlockLimitListener().removeBlock(b); + b.setType(Material.AIR); + } } - /** - * Detects and removes wither construction blocks. - * - * @param location Location of wither spawn - */ private void detectWither(Location location) { Block legs = location.getBlock(); addon.getBlockLimitListener().removeBlock(legs); @@ -340,7 +490,8 @@ private void detectWither(Location location) { } private boolean isWitherHead(Block block) { - return block.getType().equals(Material.WITHER_SKELETON_SKULL) || block.getType().equals(Material.WITHER_SKELETON_WALL_SKULL); + return block.getType().equals(Material.WITHER_SKELETON_SKULL) + || block.getType().equals(Material.WITHER_SKELETON_WALL_SKULL); } private boolean eraseWitherIfArmsMatch(Block body, Block head, BlockFace bf) { @@ -371,12 +522,6 @@ && isWitherHead(head2) && isWitherHead(head3)) { return false; } - /** - * Checks if the block is a valid wither base block. - * - * @param block Block to check - * @return true if block is a wither base block - */ private boolean isWither(Block block) { if (Util.getMinecraftVersion() < 16) { return block.getType().equals(Material.SOUL_SAND); @@ -384,15 +529,7 @@ private boolean isWither(Block block) { return Tag.WITHER_SUMMON_BASE_BLOCKS.isTagged(block.getType()); } - /** - * Notifies players within a 5x5x5 radius that entity spawning was denied. - * - * @param location Location of denied spawn - * @param entity Entity that was denied - * @param spawnReason Reason for spawning - * @param atLimitResult Result of limit check - */ - private void tellPlayers(Location location, Entity entity, SpawnReason spawnReason, AtLimitResult atLimitResult) { + private void tellPlayers(Location location, Entity entity, SpawnReason spawnReason, AtLimitResult res) { if (spawnReason.equals(SpawnReason.SPAWNER) || spawnReason.equals(SpawnReason.NATURAL) || spawnReason.equals(SpawnReason.INFECTION) || spawnReason.equals(SpawnReason.NETHER_PORTAL) || spawnReason.equals(SpawnReason.REINFORCEMENTS) || spawnReason.equals(SpawnReason.SLIME_SPLIT)) { @@ -404,104 +541,89 @@ private void tellPlayers(Location location, Entity entity, SpawnReason spawnReas for (Entity ent : w.getNearbyEntities(location, 5, 5, 5)) { if (ent instanceof Player p) { p.updateInventory(); - if (atLimitResult.getTypelimit() != null) { - User.getInstance(p).notify("entity-limits.hit-limit", "[entity]", + if (res.getTypelimit() != null) { + User.getInstance(p).notify(ENTITY_LIMIT_HIT, ENTITY_PLACEHOLDER, Util.prettifyText(entity.getType().toString()), - TextVariables.NUMBER, String.valueOf(atLimitResult.getTypelimit().getValue())); + TextVariables.NUMBER, String.valueOf(res.getTypelimit().getValue())); } else { - User.getInstance(p).notify("entity-limits.hit-limit", "[entity]", - atLimitResult.getGrouplimit().getKey().getName() + " (" + atLimitResult.getGrouplimit().getKey().getTypes().stream().map(x -> Util.prettifyText(x.toString())).collect(Collectors.joining(", ")) + ")", - TextVariables.NUMBER, String.valueOf(atLimitResult.getGrouplimit().getValue())); + User.getInstance(p).notify(ENTITY_LIMIT_HIT, ENTITY_PLACEHOLDER, + res.getGrouplimit().getKey().getName() + " (" + + res.getGrouplimit().getKey().getTypes().stream() + .map(x -> Util.prettifyText(x.toString())) + .collect(Collectors.joining(", ")) + + ")", + TextVariables.NUMBER, String.valueOf(res.getGrouplimit().getValue())); } } } }); - } /** - * Checks if new entities can be added to the island. - * - * @param island Island to check - * @param entity Entity to check - * @return AtLimitResult indicating if at limit + * Check whether a new entity of the given type would put the island over its limit + * for the entity's current environment. */ AtLimitResult atLimit(Island island, Entity entity) { - // Check island settings first - int limitAmount = -1; - Map groupsLimits = new HashMap<>(); + return atLimit(island, entity.getType(), envOf(entity.getWorld())); + } + AtLimitResult atLimit(Island island, EntityType type, Environment env) { @Nullable IslandBlockCount ibc = addon.getBlockLimitListener().getIsland(island.getUniqueId()); - if (ibc != null) { - // Get the limit amount for this type - limitAmount = ibc.getEntityLimit(entity.getType()); - // Handle entity groups from island settings - collectIslandGroupLimits(ibc, entity, groupsLimits); - } - // If no island settings then try global settings - if (limitAmount < 0 && addon.getSettings().getLimits().containsKey(entity.getType())) { - limitAmount = addon.getSettings().getLimits().get(entity.getType()); - } - // Apply global group limits where not already set by island settings - applyGlobalGroupLimits(entity, groupsLimits); + + int limitAmount = resolveTypeLimit(ibc, env, type); + Map groupsLimits = resolveGroupLimits(ibc, env, type); + if (limitAmount < 0 && groupsLimits.isEmpty()) { return new AtLimitResult(); } - // We have to count the entities - if (limitAmount >= 0) { - int count = (int) entity.getWorld().getNearbyEntities(island.getBoundingBox()).stream() - .filter(e -> e.getType().equals(entity.getType())) - .count(); - int max = limitAmount + (ibc == null ? 0 : ibc.getEntityLimitOffset(entity.getType())); - if (count >= max) { - return new AtLimitResult(entity.getType(), max); - } - } - // Update group limits from island-specific overrides - updateGroupLimitsFromIbc(ibc, groupsLimits); - // Now do the group limits - return checkGroupLimits(island, entity, ibc, groupsLimits); - } + AtLimitResult typeResult = checkTypeLimit(ibc, env, type, limitAmount); + if (typeResult.hit()) return typeResult; - private void collectIslandGroupLimits(IslandBlockCount ibc, Entity entity, Map groupsLimits) { - List groupdefs = addon.getSettings().getGroupLimits().getOrDefault(entity.getType(), - new ArrayList<>()); - groupdefs.forEach(def -> { - int limit = ibc.getEntityGroupLimit(def.getName()); - if (limit >= 0) - groupsLimits.put(def, limit); - }); + return checkGroupLimits(ibc, env, groupsLimits); } - private void applyGlobalGroupLimits(Entity entity, Map groupsLimits) { - if (addon.getSettings().getGroupLimits().containsKey(entity.getType())) { - addon.getSettings().getGroupLimits().getOrDefault(entity.getType(), new ArrayList<>()).stream() - .filter(group -> !groupsLimits.containsKey(group) || groupsLimits.get(group) > group.getLimit()) - .forEach(group -> groupsLimits.put(group, group.getLimit())); + private int resolveTypeLimit(@Nullable IslandBlockCount ibc, Environment env, EntityType type) { + int limit = ibc == null ? -1 : ibc.getEntityLimit(env, type); + if (limit < 0) { + Integer cfg = addon.getSettings().getLimits(env).get(type); + if (cfg != null) limit = cfg; } + return limit; } - private void updateGroupLimitsFromIbc(@Nullable IslandBlockCount ibc, Map groupsLimits) { - if (ibc != null) { - Map groupbyname = groupsLimits.keySet().stream() - .collect(Collectors.toMap(EntityGroup::getName, e -> e)); - ibc.getEntityGroupLimits().entrySet().stream() - .filter(e -> groupbyname.containsKey(e.getKey())) - .forEach(e -> groupsLimits.put(groupbyname.get(e.getKey()), e.getValue())); + private Map resolveGroupLimits(@Nullable IslandBlockCount ibc, Environment env, + EntityType type) { + Map groupsLimits = new HashMap<>(); + List groupdefs = addon.getSettings().getGroupLimits().getOrDefault(type, Collections.emptyList()); + Map envGroupCfg = addon.getSettings().getGroupLimits(env); + Map islandGroupOverrides = ibc == null ? Collections.emptyMap() + : ibc.getEntityGroupLimits(env); + for (EntityGroup def : groupdefs) { + int limit = islandGroupOverrides.getOrDefault(def.getName(), envGroupCfg.getOrDefault(def.getName(), -1)); + if (limit >= 0) { + groupsLimits.put(def, limit); + } } + return groupsLimits; + } + + private AtLimitResult checkTypeLimit(@Nullable IslandBlockCount ibc, Environment env, EntityType type, + int limitAmount) { + if (limitAmount < 0) return new AtLimitResult(); + int count = ibc == null ? 0 : ibc.getEntityCount(env, type); + int max = limitAmount + (ibc == null ? 0 : ibc.getEntityLimitOffset(env, type)); + return count >= max ? new AtLimitResult(type, max) : new AtLimitResult(); } - private AtLimitResult checkGroupLimits(Island island, Entity entity, @Nullable IslandBlockCount ibc, + private AtLimitResult checkGroupLimits(@Nullable IslandBlockCount ibc, Environment env, Map groupsLimits) { - for (Map.Entry group : groupsLimits.entrySet()) { //do not use lambda - if (group.getValue() < 0) - continue; - int count = (int) entity.getWorld().getNearbyEntities(island.getBoundingBox()).stream() - .filter(e -> group.getKey().contains(e.getType())) - .count(); - int max = group.getValue() + (ibc == null ? 0 : ibc.getEntityGroupLimitOffset(group.getKey().getName())); + for (Map.Entry group : groupsLimits.entrySet()) { + if (group.getValue() < 0) continue; + int count = sumGroupCount(ibc, env, group.getKey()); + int max = group.getValue() + + (ibc == null ? 0 : ibc.getEntityGroupLimitOffset(env, group.getKey().getName())); if (count >= max) { return new AtLimitResult(group.getKey(), max); } @@ -509,66 +631,41 @@ private AtLimitResult checkGroupLimits(Island island, Entity entity, @Nullable I return new AtLimitResult(); } - /** - * Result class for entity limit checks. - */ + private int sumGroupCount(@Nullable IslandBlockCount ibc, Environment env, EntityGroup group) { + if (ibc == null) return 0; + Map counts = ibc.getEntityCounts(env); + int total = 0; + for (EntityType t : group.getTypes()) { + total += counts.getOrDefault(t, 0); + } + return total; + } + static class AtLimitResult { private Map.Entry typelimit; private Map.Entry grouplimit; - /** - * Default constructor for AtLimitResult. - */ public AtLimitResult() { } - /** - * Constructor for type limit result. - * - * @param type EntityType at limit - * @param limit Limit value - */ public AtLimitResult(EntityType type, int limit) { typelimit = new AbstractMap.SimpleEntry<>(type, limit); } - /** - * Constructor for group limit result. - * - * @param type EntityGroup at limit - * @param limit Limit value - */ public AtLimitResult(EntityGroup type, int limit) { grouplimit = new AbstractMap.SimpleEntry<>(type, limit); } - /** - * Returns true if at limit. - * - * @return true if at limit - */ public boolean hit() { return typelimit != null || grouplimit != null; } - /** - * Gets the type limit entry. - * - * @return Entry of EntityType and limit - */ public Map.Entry getTypelimit() { return typelimit; } - /** - * Gets the group limit entry. - * - * @return Entry of EntityGroup and limit - */ public Map.Entry getGrouplimit() { return grouplimit; } } } - - diff --git a/src/main/java/world/bentobox/limits/listeners/JoinListener.java b/src/main/java/world/bentobox/limits/listeners/JoinListener.java index 386a322..1fcafa2 100644 --- a/src/main/java/world/bentobox/limits/listeners/JoinListener.java +++ b/src/main/java/world/bentobox/limits/listeners/JoinListener.java @@ -1,349 +1,316 @@ -package world.bentobox.limits.listeners; - -import java.util.Arrays; -import java.util.Locale; -import java.util.Objects; -import java.util.UUID; - -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.OfflinePlayer; -import org.bukkit.World; -import org.bukkit.entity.EntityType; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.permissions.PermissionAttachmentInfo; -import org.eclipse.jdt.annotation.NonNull; - -import world.bentobox.bentobox.api.events.island.IslandEvent; -import world.bentobox.bentobox.api.events.island.IslandEvent.Reason; -import world.bentobox.bentobox.api.events.team.TeamSetownerEvent; -import world.bentobox.bentobox.database.objects.Island; -import world.bentobox.limits.EntityGroup; -import world.bentobox.limits.Limits; -import world.bentobox.limits.events.LimitsJoinPermCheckEvent; -import world.bentobox.limits.events.LimitsPermCheckEvent; -import world.bentobox.limits.objects.IslandBlockCount; - -/** - * This listener handles events related to players joining, island creation, and ownership changes - * to apply permission-based block, entity, and entity group limits to islands. - * It checks player permissions and dynamically adjusts the limits for each island they own. - * - * @author tastybento - * - */ -public class JoinListener implements Listener { - - private final Limits addon; - - /** - * Constructs the listener. - * @param addon The Limits addon instance. - */ - public JoinListener(Limits addon) { - this.addon = addon; - } - - /** - * Checks a player's permissions and applies any limits to a specific island. - * The permissions are expected to be in the format: [permissionPrefix].[MATERIAL|ENTITY|GROUP]. - * E.g., "bskyblock.island.limit.COBBLESTONE.100" - * - * @param player - The player whose permissions are being checked. - * @param permissionPrefix - The prefix for the limit permissions (e.g., "bskyblock.island.limit."). - * @param islandId - The unique ID of the island to apply limits to. - * @param gameMode - The name of the game mode the island belongs to. - */ - public void checkPerms(Player player, String permissionPrefix, String islandId, String gameMode) { - // Get the existing block counts and limits for the island. - IslandBlockCount islandBlockCount = addon.getBlockLimitListener().getIsland(islandId); - // Check permissions - if (islandBlockCount != null) { - // Clear any previously set permission-based limits before re-evaluating. - // This ensures that limits are fresh on each check and removed permissions are respected. - islandBlockCount.getEntityLimits().clear(); - islandBlockCount.getEntityGroupLimits().clear(); - islandBlockCount.getBlockLimits().clear(); - } - // Iterate through all effective permissions of the player. - for (PermissionAttachmentInfo permissionInfo : player.getEffectivePermissions()) { - // Skip permissions that are not set to true, don't match the prefix, or have bad syntax. - if (!permissionInfo.getValue() || !permissionInfo.getPermission().startsWith(permissionPrefix) - || badSyntaxCheck(permissionInfo, player.getName(), permissionPrefix)) { - continue; - } - // Split the permission string to parse it. E.g., "bskyblock.island.limit.COBBLESTONE.100" - String[] permissionParts = permissionInfo.getPermission().split("\\."); - // Try to match the relevant part of the permission to an EntityType, Material, or a defined EntityGroup. - EntityType entityType = Arrays.stream(EntityType.values()).filter(type -> type.name().equalsIgnoreCase(permissionParts[3])) - .findFirst().orElse(null); - Material material = Arrays.stream(Material.values()).filter(mat -> mat.name().equalsIgnoreCase(permissionParts[3])).findFirst() - .orElse(null); - EntityGroup entityGroup = addon.getSettings().getGroupLimitDefinitions().stream() - .filter(group -> group.getName().equalsIgnoreCase(permissionParts[3])).findFirst().orElse(null); - - // If the permission part is not a valid type, log an error and stop processing this permission. - if (entityGroup == null && entityType == null && material == null) { - logError(player.getName(), permissionInfo.getPermission(), - permissionParts[3].toUpperCase(Locale.ENGLISH) + " is not a valid material or entity type/group."); - break; - } - // If this is the first limit being applied, create a new IslandBlockCount object. - if (islandBlockCount == null) { - islandBlockCount = new IslandBlockCount(islandId, gameMode); - } - // The last part of the permission is the limit value. - int limitValue = Integer.parseInt(permissionParts[4]); - logIfEnabled("Setting login limit via perm for " + player.getName() + "..."); - - // Fire a custom event to allow other plugins to modify or cancel the limit application. - LimitsPermCheckEvent limitsPermCheckEvent = new LimitsPermCheckEvent(player, islandId, islandBlockCount, entityGroup, entityType, material, limitValue); - Bukkit.getPluginManager().callEvent(limitsPermCheckEvent); - if (limitsPermCheckEvent.isCancelled()) { - addon.log("Permissions not set because another addon/plugin canceled setting."); - continue; - } - // Update local variables with any changes from the event. - islandBlockCount = limitsPermCheckEvent.getIbc(); - // If the event handler nulled the IslandBlockCount, create a new one. - if (islandBlockCount == null) { - islandBlockCount = new IslandBlockCount(islandId, gameMode); - } - // Apply the limit to the island's block/entity counts. - runNullCheckAndSet(islandBlockCount, limitsPermCheckEvent); - } - // After checking all permissions, save the updated IslandBlockCount if it has been modified. - if (islandBlockCount != null) - addon.getBlockLimitListener().setIsland(islandId, islandBlockCount); - } - - /** - * Validates the syntax of a limit permission string. - * @param permissionInfo The permission to check. - * @param playerName The name of the player for logging purposes. - * @param permissionPrefix The required prefix for the permission. - * @return true if the syntax is bad, false otherwise. - */ - private boolean badSyntaxCheck(PermissionAttachmentInfo permissionInfo, String playerName, String permissionPrefix) { - // Wildcard permissions are not supported for limits. - if (permissionInfo.getPermission().contains(permissionPrefix + "*")) { - logError(playerName, permissionInfo.getPermission(), "wildcards are not allowed."); - return true; - } - // The permission must have exactly 5 parts separated by dots. - String[] permissionParts = permissionInfo.getPermission().split("\\."); - if (permissionParts.length != 5) { - logError(playerName, permissionInfo.getPermission(), "format must be '" + permissionPrefix + "MATERIAL.NUMBER', '" - + permissionPrefix + "ENTITY-TYPE.NUMBER', or '" + permissionPrefix + "ENTITY-GROUP.NUMBER'"); - return true; - } - // The last part of the permission must be a valid integer. - try { - Integer.parseInt(permissionParts[4]); - } catch (Exception e) { - logError(playerName, permissionInfo.getPermission(), "the last part MUST be an integer!"); - return true; - } - return false; - } - - /** - * Applies the limit from a permission check event to the island's block/entity counts. - * It ensures that if multiple permissions grant a limit for the same thing, the highest limit is used. - * @param islandBlockCount The island's count object to modify. - * @param event The event containing the limit information. - */ - private void runNullCheckAndSet(@NonNull IslandBlockCount islandBlockCount, @NonNull LimitsPermCheckEvent event) { - EntityGroup entityGroup = event.getEntityGroup(); - EntityType entityType = event.getEntityType(); - Material material = event.getMaterial(); - int limitValue = event.getValue(); - if (entityGroup != null) { - int newLimit = Math.max(islandBlockCount.getEntityGroupLimit(entityGroup.getName()), limitValue); - islandBlockCount.setEntityGroupLimit(entityGroup.getName(), newLimit); - logIfEnabled("Setting group limit " + entityGroup.getName() + " " + newLimit); - } else if (entityType != null && material == null) { - int newLimit = Math.max(islandBlockCount.getEntityLimit(entityType), limitValue); - islandBlockCount.setEntityLimit(entityType, newLimit); - logIfEnabled("Setting entity limit " + entityType + " " + newLimit); - } else if (material != null && entityType == null) { - int newLimit = Math.max(islandBlockCount.getBlockLimit(material.getKey()), limitValue); - logIfEnabled("Setting block limit " + material + " " + newLimit); - islandBlockCount.setBlockLimit(material.getKey(), newLimit); - } else { - applyAmbiguousLimit(islandBlockCount, entityType, material, limitValue); - } - } - - private void applyAmbiguousLimit(@NonNull IslandBlockCount islandBlockCount, EntityType entityType, Material material, int limitValue) { - if (material != null && material.isBlock()) { - int newLimit = Math.max(islandBlockCount.getBlockLimit(material.getKey()), limitValue); - logIfEnabled("Setting block limit " + material + " " + newLimit); - islandBlockCount.setBlockLimit(material.getKey(), newLimit); - } else if (entityType != null) { - int newLimit = Math.max(islandBlockCount.getEntityLimit(entityType), limitValue); - logIfEnabled("Setting entity limit " + entityType + " " + newLimit); - islandBlockCount.setEntityLimit(entityType, newLimit); - } - } - - private void logIfEnabled(String message) { - if (addon.getSettings().isLogLimitsOnJoin()) { - addon.log(message); - } - } - - /** - * Logs an error message related to a permission. - * @param playerName The player with the problematic permission. - * @param permission The permission string. - * @param errorMessage The error description. - */ - private void logError(String playerName, String permission, String errorMessage) { - addon.logError("Player " + playerName + " has permission: '" + permission + "' but " + errorMessage + " Ignoring..."); - } - - /* - * Event handling - */ - - /** - * Handles island creation, reset, and registration events. - * When a new island is made available, set the owner's permission-based limits. - * @param event The island event. - */ - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void onNewIsland(IslandEvent event) { - if (!event.getReason().equals(Reason.CREATED) && !event.getReason().equals(Reason.RESETTED) - && !event.getReason().equals(Reason.REGISTERED)) { - return; - } - setOwnerPerms(event.getIsland(), event.getOwner()); - } - - /** - * Handles island ownership changes. - * Removes the old owner's limits and applies the new owner's limits. - * @param event The team set owner event. - */ - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void onOwnerChange(TeamSetownerEvent event) { - removeOwnerPerms(event.getIsland()); - setOwnerPerms(event.getIsland(), event.getNewOwner()); - } - - /** - * Handles player join events. - * When a player joins, iterate through all game modes and check their owned islands - * to ensure their limits are up-to-date. - * @param event The player join event. - */ - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - public void onPlayerJoin(PlayerJoinEvent event) { - // Check if player has any islands in the game modes - addon.getGameModes().forEach(gameMode -> { - addon.getIslands().getIslands(gameMode.getOverWorld(), event.getPlayer().getUniqueId()).stream() - // Filter to only islands they own. - .filter(island -> event.getPlayer().getUniqueId().equals(island.getOwner())) - .map(Island::getUniqueId).forEach(islandId -> { - IslandBlockCount islandBlockCount = addon.getBlockLimitListener().getIsland(islandId); - // Fire an event to see if this check should be cancelled, then run the permission check. - if (!joinEventCheck(event.getPlayer(), islandId, islandBlockCount)) { - checkPerms(event.getPlayer(), gameMode.getPermissionPrefix() + "island.limit.", islandId, - gameMode.getDescription().getName()); - } - }); - }); - } - - /** - * Fires a custom event before applying permissions on player join. - * This allows other addons to cancel the permission check or provide a custom IslandBlockCount object. - * - * @param player The player joining. - * @param islandId The ID of the island being checked. - * @param islandBlockCount The current IslandBlockCount for the island. - * @return true if the permission check should be cancelled, false otherwise. - */ - private boolean joinEventCheck(Player player, String islandId, IslandBlockCount islandBlockCount) { - // Fire event, so other addons can cancel this permissions change - LimitsJoinPermCheckEvent event = new LimitsJoinPermCheckEvent(player, islandId, islandBlockCount); - Bukkit.getPluginManager().callEvent(event); - if (event.isCancelled()) { - return true; - } - // Get islandBlockCount from event if it has changed - islandBlockCount = event.getIbc(); - // If perms should be ignored, but the IBC given in the event used, then set it - // and return true to cancel the normal permission check. - if (event.isIgnorePerms() && islandBlockCount != null) { - addon.getBlockLimitListener().setIsland(islandId, islandBlockCount); - return true; - } - return false; - } - - /** - * Handles island un-registration (deletion). - * Removes any limits associated with the island. - * @param event The island event. - */ - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void onUnregisterIsland(IslandEvent event) { - if (!event.getReason().equals(Reason.UNREGISTERED)) { - return; - } - removeOwnerPerms(event.getIsland()); - } - - /* - * Utility methods - */ - - /** - * Removes all permission-based limits from an island. - * This is typically done when an owner changes or the island is deleted. - * @param island The island to clear limits from. - */ - private void removeOwnerPerms(Island island) { - World world = island.getWorld(); - if (addon.inGameModeWorld(world)) { - IslandBlockCount islandBlockCount = addon.getBlockLimitListener().getIsland(island.getUniqueId()); - if (islandBlockCount != null) { - // Just clear the maps. This preserves any actual block counts. - islandBlockCount.getBlockLimits().clear(); - islandBlockCount.getEntityLimits().clear(); - islandBlockCount.getEntityGroupLimits().clear(); - } - } - } - - /** - * Applies permission-based limits for an island's owner. - * It only runs if the owner is online. - * @param island The island to apply limits to. - * @param ownerUUID The UUID of the owner. - */ - private void setOwnerPerms(Island island, UUID ownerUUID) { - World world = island.getWorld(); - if (addon.inGameModeWorld(world)) { - // Check if owner is online - OfflinePlayer owner = Bukkit.getOfflinePlayer(ownerUUID); - if (owner.isOnline()) { - // Set perm-based limits - String permissionPrefix = addon.getGameModePermPrefix(world); - String gameModeName = addon.getGameModeName(world); - if (!permissionPrefix.isEmpty() && !gameModeName.isEmpty() && owner.getPlayer() != null) { - // Run the main permission check logic. - checkPerms(Objects.requireNonNull(owner.getPlayer()), permissionPrefix + "island.limit.", - island.getUniqueId(), gameModeName); - } - } - } - } - -} +package world.bentobox.limits.listeners; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.World; +import org.bukkit.World.Environment; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.permissions.PermissionAttachmentInfo; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.bentobox.api.events.island.IslandEvent; +import world.bentobox.bentobox.api.events.island.IslandEvent.Reason; +import world.bentobox.bentobox.api.events.team.TeamSetownerEvent; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.limits.EntityGroup; +import world.bentobox.limits.Limits; +import world.bentobox.limits.Settings; +import world.bentobox.limits.events.LimitsJoinPermCheckEvent; +import world.bentobox.limits.events.LimitsPermCheckEvent; +import world.bentobox.limits.objects.IslandBlockCount; + +/** + * Applies permission-based block, entity, and entity-group limits to islands. + * + *

Permission format: + *

    + *
  • 5-segment: {@code .island.limit..} — same limit applied independently + * to every environment.
  • + *
  • 6-segment: {@code .island.limit...} — applied only to the named + * environment, where {@code env} is one of {@code overworld}, {@code nether}, + * {@code end}.
  • + *
+ * + * @author tastybento + */ +public class JoinListener implements Listener { + + private final Limits addon; + + public JoinListener(Limits addon) { + this.addon = addon; + } + + /** + * Reads every limit-shaped permission the player has and re-applies it to the island + * after first clearing existing permission-based limits. + */ + public void checkPerms(Player player, String permissionPrefix, String islandId, String gameMode) { + IslandBlockCount islandBlockCount = addon.getBlockLimitListener().getIsland(islandId); + if (islandBlockCount != null) { + islandBlockCount.clearAllEntityLimits(); + islandBlockCount.clearAllEntityGroupLimits(); + islandBlockCount.clearAllBlockLimits(); + } + for (PermissionAttachmentInfo permissionInfo : player.getEffectivePermissions()) { + if (!permissionInfo.getValue() || !permissionInfo.getPermission().startsWith(permissionPrefix)) { + continue; + } + islandBlockCount = applyOnePerm(player, permissionInfo, permissionPrefix, islandId, gameMode, + islandBlockCount); + } + if (islandBlockCount != null) { + addon.getBlockLimitListener().setIsland(islandId, islandBlockCount); + } + } + + private IslandBlockCount applyOnePerm(Player player, PermissionAttachmentInfo permissionInfo, + String permissionPrefix, String islandId, String gameMode, IslandBlockCount current) { + ParsedPerm parsed = parsePerm(permissionInfo, player.getName(), permissionPrefix); + if (parsed == null) return current; + + // Try to match the key part to an EntityType, Material, or EntityGroup + EntityType entityType = matchEntityType(parsed.key); + Material material = matchMaterial(parsed.key); + EntityGroup entityGroup = matchEntityGroup(parsed.key); + + if (entityGroup == null && entityType == null && material == null) { + logError(player.getName(), permissionInfo.getPermission(), + parsed.key.toUpperCase(Locale.ENGLISH) + " is not a valid material or entity type/group."); + return current; + } + + IslandBlockCount ibc = current != null ? current : new IslandBlockCount(islandId, gameMode); + logIfEnabled("Setting login limit via perm for " + player.getName() + "..."); + + LimitsPermCheckEvent limitsPermCheckEvent = new LimitsPermCheckEvent(player, islandId, ibc, entityGroup, + entityType, material, parsed.value); + Bukkit.getPluginManager().callEvent(limitsPermCheckEvent); + if (limitsPermCheckEvent.isCancelled()) { + addon.log("Permissions not set because another addon/plugin canceled setting."); + return ibc; + } + IslandBlockCount eventIbc = limitsPermCheckEvent.getIbc(); + IslandBlockCount finalIbc = eventIbc != null ? eventIbc : new IslandBlockCount(islandId, gameMode); + applyLimit(finalIbc, parsed.envs, limitsPermCheckEvent); + return finalIbc; + } + + /** + * Parses a limit permission. Returns {@code null} if invalid (and logs). + */ + private ParsedPerm parsePerm(PermissionAttachmentInfo info, String playerName, String permissionPrefix) { + String permission = info.getPermission(); + if (permission.contains(permissionPrefix + "*")) { + logError(playerName, permission, "wildcards are not allowed."); + return null; + } + String[] parts = permission.split("\\."); + // 5-segment: .island.limit.. + // 6-segment: .island.limit... + if (parts.length != 5 && parts.length != 6) { + logError(playerName, permission, "format must be '" + permissionPrefix + "[ENV.]KEY.NUMBER' " + + "where ENV is overworld|nether|end and KEY is a material, entity type, or group name."); + return null; + } + String numberPart = parts[parts.length - 1]; + int value; + try { + value = Integer.parseInt(numberPart); + } catch (NumberFormatException e) { + logError(playerName, permission, "the last part MUST be an integer!"); + return null; + } + ParsedPerm out = new ParsedPerm(); + out.value = value; + if (parts.length == 5) { + out.key = parts[3]; + out.envs = Settings.ENVIRONMENTS; + } else { + Environment env = parseEnv(parts[3]); + if (env == null) { + logError(playerName, permission, "'" + parts[3] + + "' is not a recognised environment (use overworld, nether, or end)."); + return null; + } + out.key = parts[4]; + out.envs = List.of(env); + } + return out; + } + + private static Environment parseEnv(String token) { + return switch (token.toLowerCase(Locale.ROOT)) { + case "overworld", "normal" -> Environment.NORMAL; + case "nether" -> Environment.NETHER; + case "end", "the_end" -> Environment.THE_END; + default -> null; + }; + } + + private EntityType matchEntityType(String key) { + return Arrays.stream(EntityType.values()).filter(t -> t.name().equalsIgnoreCase(key)).findFirst().orElse(null); + } + + private Material matchMaterial(String key) { + return Arrays.stream(Material.values()).filter(m -> m.name().equalsIgnoreCase(key)).findFirst().orElse(null); + } + + private EntityGroup matchEntityGroup(String key) { + return addon.getSettings().getGroupLimitDefinitions().stream() + .filter(group -> group.getName().equalsIgnoreCase(key)).findFirst().orElse(null); + } + + private void applyLimit(@NonNull IslandBlockCount ibc, List envs, @NonNull LimitsPermCheckEvent event) { + EntityGroup entityGroup = event.getEntityGroup(); + EntityType entityType = event.getEntityType(); + Material material = event.getMaterial(); + int limitValue = event.getValue(); + + if (entityGroup != null) { + for (Environment env : envs) { + int newLimit = Math.max(ibc.getEntityGroupLimit(env, entityGroup.getName()), limitValue); + ibc.setEntityGroupLimit(env, entityGroup.getName(), newLimit); + logIfEnabled("Setting group limit " + entityGroup.getName() + " in " + env + " to " + newLimit); + } + } else if (entityType != null && material == null) { + for (Environment env : envs) { + int newLimit = Math.max(ibc.getEntityLimit(env, entityType), limitValue); + ibc.setEntityLimit(env, entityType, newLimit); + logIfEnabled("Setting entity limit " + entityType + " in " + env + " to " + newLimit); + } + } else if (material != null && entityType == null) { + for (Environment env : envs) { + int newLimit = Math.max(ibc.getBlockLimit(env, material.getKey()), limitValue); + ibc.setBlockLimit(env, material.getKey(), newLimit); + logIfEnabled("Setting block limit " + material + " in " + env + " to " + newLimit); + } + } else { + applyAmbiguousLimit(ibc, envs, entityType, material, limitValue); + } + } + + private void applyAmbiguousLimit(@NonNull IslandBlockCount ibc, List envs, EntityType entityType, + Material material, int limitValue) { + if (material != null && material.isBlock()) { + for (Environment env : envs) { + int newLimit = Math.max(ibc.getBlockLimit(env, material.getKey()), limitValue); + ibc.setBlockLimit(env, material.getKey(), newLimit); + logIfEnabled("Setting block limit " + material + " in " + env + " to " + newLimit); + } + } else if (entityType != null) { + for (Environment env : envs) { + int newLimit = Math.max(ibc.getEntityLimit(env, entityType), limitValue); + ibc.setEntityLimit(env, entityType, newLimit); + logIfEnabled("Setting entity limit " + entityType + " in " + env + " to " + newLimit); + } + } + } + + private void logIfEnabled(String message) { + if (addon.getSettings().isLogLimitsOnJoin()) { + addon.log(message); + } + } + + private void logError(String playerName, String permission, String errorMessage) { + addon.logError("Player " + playerName + " has permission: '" + permission + "' but " + errorMessage + + " Ignoring..."); + } + + /* ========================================================================= + * Event handlers + * ========================================================================= */ + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onNewIsland(IslandEvent event) { + if (!event.getReason().equals(Reason.CREATED) && !event.getReason().equals(Reason.RESETTED) + && !event.getReason().equals(Reason.REGISTERED)) { + return; + } + setOwnerPerms(event.getIsland(), event.getOwner()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onOwnerChange(TeamSetownerEvent event) { + removeOwnerPerms(event.getIsland()); + setOwnerPerms(event.getIsland(), event.getNewOwner()); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onPlayerJoin(PlayerJoinEvent event) { + addon.getGameModes().forEach(gameMode -> addon.getIslands() + .getIslands(gameMode.getOverWorld(), event.getPlayer().getUniqueId()).stream() + .filter(island -> event.getPlayer().getUniqueId().equals(island.getOwner())) + .map(Island::getUniqueId).forEach(islandId -> { + IslandBlockCount islandBlockCount = addon.getBlockLimitListener().getIsland(islandId); + if (!joinEventCheck(event.getPlayer(), islandId, islandBlockCount)) { + checkPerms(event.getPlayer(), gameMode.getPermissionPrefix() + "island.limit.", islandId, + gameMode.getDescription().getName()); + } + })); + } + + private boolean joinEventCheck(Player player, String islandId, IslandBlockCount islandBlockCount) { + LimitsJoinPermCheckEvent event = new LimitsJoinPermCheckEvent(player, islandId, islandBlockCount); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) return true; + islandBlockCount = event.getIbc(); + if (event.isIgnorePerms() && islandBlockCount != null) { + addon.getBlockLimitListener().setIsland(islandId, islandBlockCount); + return true; + } + return false; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onUnregisterIsland(IslandEvent event) { + if (!event.getReason().equals(Reason.UNREGISTERED)) return; + removeOwnerPerms(event.getIsland()); + } + + private void removeOwnerPerms(Island island) { + World world = island.getWorld(); + if (addon.inGameModeWorld(world)) { + IslandBlockCount islandBlockCount = addon.getBlockLimitListener().getIsland(island.getUniqueId()); + if (islandBlockCount != null) { + islandBlockCount.clearAllBlockLimits(); + islandBlockCount.clearAllEntityLimits(); + islandBlockCount.clearAllEntityGroupLimits(); + } + } + } + + private void setOwnerPerms(Island island, UUID ownerUUID) { + World world = island.getWorld(); + if (addon.inGameModeWorld(world)) { + OfflinePlayer owner = Bukkit.getOfflinePlayer(ownerUUID); + if (owner.isOnline()) { + String permissionPrefix = addon.getGameModePermPrefix(world); + String gameModeName = addon.getGameModeName(world); + if (!permissionPrefix.isEmpty() && !gameModeName.isEmpty() && owner.getPlayer() != null) { + checkPerms(Objects.requireNonNull(owner.getPlayer()), permissionPrefix + "island.limit.", + island.getUniqueId(), gameModeName); + } + } + } + } + + private static class ParsedPerm { + String key; + int value; + List envs; + } +} diff --git a/src/main/java/world/bentobox/limits/objects/EnvNamespacedKeyMapAdapter.java b/src/main/java/world/bentobox/limits/objects/EnvNamespacedKeyMapAdapter.java new file mode 100644 index 0000000..68124ea --- /dev/null +++ b/src/main/java/world/bentobox/limits/objects/EnvNamespacedKeyMapAdapter.java @@ -0,0 +1,116 @@ +package world.bentobox.limits.objects; + +import java.io.IOException; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import org.bukkit.NamespacedKey; +import org.bukkit.World.Environment; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +/** + * Gson {@link TypeAdapter} for {@code Map>}. + * Outer keys are {@link Environment} names; inner keys are written/read as + * "namespace:key" strings via the same logic as {@link NamespacedKeyMapAdapter}. + */ +public class EnvNamespacedKeyMapAdapter extends TypeAdapter>> { + + @Override + public void write(JsonWriter out, Map> value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + out.beginObject(); + for (Map.Entry> envEntry : value.entrySet()) { + if (envEntry.getKey() == null || envEntry.getValue() == null || envEntry.getValue().isEmpty()) { + continue; + } + out.name(envEntry.getKey().name()); + out.beginObject(); + for (Map.Entry entry : envEntry.getValue().entrySet()) { + if (entry.getKey() == null || entry.getValue() == null) { + continue; + } + out.name(entry.getKey().toString()); + out.value(entry.getValue()); + } + out.endObject(); + } + out.endObject(); + } + + @Override + public Map> read(JsonReader in) throws IOException { + Map> result = new EnumMap<>(Environment.class); + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return result; + } + in.beginObject(); + while (in.hasNext()) { + String envName = in.nextName(); + Environment env = parseEnvironment(envName); + Map inner = readInner(in); + if (env != null && !inner.isEmpty()) { + result.put(env, inner); + } + } + in.endObject(); + return result; + } + + private static Environment parseEnvironment(String raw) { + if (raw == null) return null; + try { + return Environment.valueOf(raw.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static Map readInner(JsonReader in) throws IOException { + Map inner = new HashMap<>(); + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return inner; + } + in.beginObject(); + while (in.hasNext()) { + String rawKey = in.nextName(); + Integer value = readIntOrNull(in); + NamespacedKey key = parseKey(rawKey); + if (key != null && value != null) { + inner.put(key, value); + } + } + in.endObject(); + return inner; + } + + private static NamespacedKey parseKey(String raw) { + if (raw == null || raw.isEmpty()) return null; + if (raw.indexOf(':') >= 0) { + return NamespacedKey.fromString(raw.toLowerCase(Locale.ROOT)); + } + try { + return new NamespacedKey(NamespacedKey.MINECRAFT, raw.toLowerCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static Integer readIntOrNull(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + return in.nextInt(); + } +} diff --git a/src/main/java/world/bentobox/limits/objects/IslandBlockCount.java b/src/main/java/world/bentobox/limits/objects/IslandBlockCount.java index ae410d9..cd4657e 100644 --- a/src/main/java/world/bentobox/limits/objects/IslandBlockCount.java +++ b/src/main/java/world/bentobox/limits/objects/IslandBlockCount.java @@ -1,432 +1,500 @@ -package world.bentobox.limits.objects; - -import java.util.EnumMap; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -import org.bukkit.NamespacedKey; -import org.bukkit.entity.EntityType; - -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.JsonAdapter; - -import world.bentobox.bentobox.database.objects.DataObject; -import world.bentobox.bentobox.database.objects.Table; - -/** - * @author tastybento - * - */ -@Table(name = "IslandBlockCount") -public class IslandBlockCount implements DataObject { - - @Expose - @JsonAdapter(NamespacedKeyMapAdapter.class) - private Map blockCounts = new HashMap<>(); - - /** - * Permission based limits - */ - @Expose - @JsonAdapter(NamespacedKeyMapAdapter.class) - private Map blockLimits = new HashMap<>(); - - @Expose - @JsonAdapter(NamespacedKeyMapAdapter.class) - private Map blockLimitsOffset = new HashMap<>(); - - private boolean changed; - - @Expose - private Map entityGroupLimits = new HashMap<>(); - @Expose - private Map entityGroupLimitsOffset = new HashMap<>(); - @Expose - private Map entityLimits = new EnumMap<>(EntityType.class); - @Expose - private Map entityLimitsOffset = new EnumMap<>(EntityType.class); - @Expose - private String gameMode; - @Expose - private String uniqueId; - - /** - * Create an island block count object - * - * @param islandId - unique Island ID string - * @param gameMode - Game mode name from gm.getDescription().getName() - */ - public IslandBlockCount(String islandId, String gameMode) { - this.uniqueId = islandId; - this.gameMode = gameMode; - setChanged(); - } - - /** - * Add a material to the count - * - * @param material - material - */ - public void add(NamespacedKey material) { - getBlockCounts().merge(material, 1, Integer::sum); - setChanged(); - } - - /** - * Clear all island-specific entity group limits - */ - public void clearEntityGroupLimits() { - entityGroupLimits.clear(); - setChanged(); - } - - /** - * Clear all island-specific entity type limits - */ - public void clearEntityLimits() { - entityLimits.clear(); - setChanged(); - } - - /** - * Get the block count for this material for this island - * - * @param m - material - * @return count - */ - public Integer getBlockCount(NamespacedKey m) { - return getBlockCounts().getOrDefault(m, 0); - } - - /** - * @return the blockCount - */ - public Map getBlockCounts() { - if (blockCounts == null) { - blockCounts = new HashMap<>(); - } - return blockCounts; - } - - /** - * Get the block limit for this material for this island - * - * @param m - material - * @return limit or -1 for unlimited - */ - public int getBlockLimit(NamespacedKey m) { - return getBlockLimits().getOrDefault(m, -1); - } - - /** - * Get the block offset for this material for this island - * - * @param m - material - * @return offset - */ - public int getBlockLimitOffset(NamespacedKey m) { - return getBlockLimitsOffset().getOrDefault(m, 0); - } - - /** - * @return the blockLimits - */ - public Map getBlockLimits() { - if (blockLimits == null) { - blockLimits = new HashMap<>(); - } - return blockLimits; - } - - /** - * @return the blockLimitsOffset - */ - public Map getBlockLimitsOffset() { - if (blockLimitsOffset == null) { - blockLimitsOffset = new HashMap<>(); - } - return blockLimitsOffset; - } - - /** - * Get the limit for an entity group - * - * @param name - entity group - * @return limit or -1 for unlimited - */ - public int getEntityGroupLimit(String name) { - return getEntityGroupLimits().getOrDefault(name, -1); - } - - /** - * Get the offset for an entity group - * - * @param name - entity group - * @return offset - */ - public int getEntityGroupLimitOffset(String name) { - return getEntityGroupLimitsOffset().getOrDefault(name, 0); - } - - /** - * @return the entityGroupLimits - */ - public Map getEntityGroupLimits() { - return Objects.requireNonNullElse(entityGroupLimits, new HashMap<>()); - } - - /** - * @return the entityGroupLimitsOffset - */ - public Map getEntityGroupLimitsOffset() { - if (entityGroupLimitsOffset == null) { - entityGroupLimitsOffset = new HashMap<>(); - } - return entityGroupLimitsOffset; - } - - /** - * Get the limit for an entity type - * - * @param t - entity type - * @return limit or -1 for unlimited - */ - public int getEntityLimit(EntityType t) { - return getEntityLimits().getOrDefault(t, -1); - } - - /** - * Get the limit offset for an entity type - * - * @param t - entity type - * @return offset - */ - public int getEntityLimitOffset(EntityType t) { - return getEntityLimitsOffset().getOrDefault(t, 0); - } - - /** - * @return the entityLimits - */ - public Map getEntityLimits() { - return Objects.requireNonNullElse(entityLimits, new EnumMap<>(EntityType.class)); - } - - /** - * @return the entityLimitsOffset - */ - public Map getEntityLimitsOffset() { - if (entityLimitsOffset == null) { - entityLimitsOffset = new EnumMap<>(EntityType.class); - } - return entityLimitsOffset; - } - - /** - * @return the gameMode - */ - public String getGameMode() { - return gameMode; - } - - /* - * (non-Javadoc) - * - * @see world.bentobox.bentobox.database.objects.DataObject#getUniqueId() - */ - @Override - public String getUniqueId() { - return uniqueId; - } - - /** - * Check if no more of this material can be added to this island - * - * @param m - material - * @return true if no more material can be added - */ - public boolean isAtLimit(NamespacedKey m) { - return getBlockLimits().containsKey(m) - && getBlockCount(m) >= getBlockLimit(m) + this.getBlockLimitOffset(m); - } - - /** - * Check if this material is at or over a limit - * - * @param material - block material - * @param limit - limit to check - * @return true if count is >= limit - */ - public boolean isAtLimit(NamespacedKey material, int limit) { - return getBlockCount(material) >= limit + this.getBlockLimitOffset(material); - } - - public boolean isBlockLimited(NamespacedKey m) { - return getBlockLimits().containsKey(m); - } - - /** - * @return the changed - */ - public boolean isChanged() { - return changed; - } - - public boolean isGameMode(String gameMode) { - return getGameMode().equals(gameMode); - } - - /** - * Remove a material from the count - * - * @param material - material - */ - public void remove(NamespacedKey material) { - // Check if this material is currently tracked - boolean existed = getBlockCounts().containsKey(material); - // Otherwise, decrement/remove individual block - getBlockCounts().computeIfPresent(material, (m, count) -> { - int newCount = count - 1; - return (newCount > 0) ? newCount : null; // null removes the entry - }); - // Mark this object as changed only if a modification actually occurred - if (existed) { - setChanged(); - } - } - - /** - * @param blockCounts the blockCount to set - */ - public void setBlockCounts(Map blockCounts) { - this.blockCounts = blockCounts; - setChanged(); - } - - /** - * Set the block limit for this material for this island - * - * @param m - material - * @param limit - maximum number allowed - */ - public void setBlockLimit(NamespacedKey m, int limit) { - getBlockLimits().put(m, limit); - setChanged(); - } - - /** - * @param blockLimits the blockLimits to set - */ - public void setBlockLimits(Map blockLimits) { - this.blockLimits = blockLimits; - setChanged(); - } - - /** - * Set an offset to a block limit. This will increase/decrease the value of the - * limit. - * - * @param m material - * @param blockLimitsOffset the blockLimitsOffset to set - */ - public void setBlockLimitsOffset(NamespacedKey m, Integer blockLimitsOffset) { - getBlockLimitsOffset().put(m, blockLimitsOffset); - } - - /** - * Mark changed - */ - public void setChanged() { - this.changed = true; - } - - /** - * @param changed the changed to set - */ - public void setChanged(boolean changed) { - this.changed = changed; - } - - /** - * Set an island-specific entity group limit - * - * @param name - entity group - * @param limit - limit - */ - public void setEntityGroupLimit(String name, int limit) { - getEntityGroupLimits().put(name, limit); - setChanged(); - } - - /** - * @param entityGroupLimits the entityGroupLimits to set - */ - public void setEntityGroupLimits(Map entityGroupLimits) { - this.entityGroupLimits = entityGroupLimits; - setChanged(); - } - - /** - * Set an offset to an entity group limit. This will increase/decrease the value - * of the limit. - * - * @param name group name - * @param entityGroupLimitsOffset the entityGroupLimitsOffset to set - */ - public void setEntityGroupLimitsOffset(String name, Integer entityGroupLimitsOffset) { - getEntityGroupLimitsOffset().put(name, entityGroupLimitsOffset); - } - - /** - * Set an island-specific entity type limit - * - * @param t - entity type - * @param limit - limit - */ - public void setEntityLimit(EntityType t, int limit) { - getEntityLimits().put(t, limit); - setChanged(); - } - - /** - * @param entityLimits the entityLimits to set - */ - public void setEntityLimits(Map entityLimits) { - this.entityLimits = entityLimits; - setChanged(); - } - - /** - * Set an offset to an entity limit. This will increase/decrease the value of - * the limit. - * - * @param type Entity Type - * @param entityLimitsOffset the entityLimitsOffset to set - */ - public void setEntityLimitsOffset(EntityType type, Integer entityLimitsOffset) { - this.getEntityLimitsOffset().put(type, entityLimitsOffset); - } - - /** - * @param gameMode the gameMode to set - */ - public void setGameMode(String gameMode) { - this.gameMode = gameMode; - setChanged(); - } - - /* - * (non-Javadoc) - * - * @see - * world.bentobox.bentobox.database.objects.DataObject#setUniqueId(java.lang. - * String) - */ - @Override - public void setUniqueId(String uniqueId) { - this.uniqueId = uniqueId; - setChanged(); - } - - } +package world.bentobox.limits.objects; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; + +import org.bukkit.NamespacedKey; +import org.bukkit.World.Environment; +import org.bukkit.entity.EntityType; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.JsonAdapter; + +import world.bentobox.bentobox.database.objects.DataObject; +import world.bentobox.bentobox.database.objects.Table; + +/** + * Per-island, per-environment block and entity tracking. + * + *

Each map is keyed by {@link Environment} so overworld, nether, and end have + * independent counts, limits and offsets. Pre-1.x data stored a single map per + * field (no environment); on first load that legacy data is migrated into the + * {@link Environment#NORMAL} slot. + * + * @author tastybento + */ +@Table(name = "IslandBlockCount") +public class IslandBlockCount implements DataObject { + + /* New env-keyed primary storage. */ + + @Expose + @JsonAdapter(EnvNamespacedKeyMapAdapter.class) + private Map> envBlockCounts = new EnumMap<>(Environment.class); + + @Expose + @JsonAdapter(EnvNamespacedKeyMapAdapter.class) + private Map> envBlockLimits = new EnumMap<>(Environment.class); + + @Expose + @JsonAdapter(EnvNamespacedKeyMapAdapter.class) + private Map> envBlockLimitsOffset = new EnumMap<>(Environment.class); + + @Expose + private Map> envEntityCounts = new EnumMap<>(Environment.class); + + @Expose + private Map> envEntityLimits = new EnumMap<>(Environment.class); + + @Expose + private Map> envEntityLimitsOffset = new EnumMap<>(Environment.class); + + @Expose + private Map> envEntityGroupLimits = new EnumMap<>(Environment.class); + + @Expose + private Map> envEntityGroupLimitsOffset = new EnumMap<>(Environment.class); + + /* Legacy fields kept to deserialize pre-env data; never written back. */ + + @Expose(serialize = false) + @JsonAdapter(NamespacedKeyMapAdapter.class) + private Map blockCounts; + + @Expose(serialize = false) + @JsonAdapter(NamespacedKeyMapAdapter.class) + private Map blockLimits; + + @Expose(serialize = false) + @JsonAdapter(NamespacedKeyMapAdapter.class) + private Map blockLimitsOffset; + + @Expose(serialize = false) + private Map entityLimits; + + @Expose(serialize = false) + private Map entityLimitsOffset; + + @Expose(serialize = false) + private Map entityGroupLimits; + + @Expose(serialize = false) + private Map entityGroupLimitsOffset; + + @Expose + private String gameMode; + + @Expose + private String uniqueId; + + private boolean changed; + private boolean migrated; + + /** + * Required by Gson. + */ + public IslandBlockCount() { + } + + /** + * @param islandId unique island ID + * @param gameMode game mode addon name + */ + public IslandBlockCount(String islandId, String gameMode) { + this.uniqueId = islandId; + this.gameMode = gameMode; + setChanged(); + } + + /* ========================================================================= + * Migration + * ========================================================================= */ + + /** + * Move any legacy single-map data into Environment.NORMAL. Idempotent. + */ + private void migrateIfNeeded() { + if (migrated) return; + migrated = true; + moveLegacy(blockCounts, envBlockCounts); + moveLegacy(blockLimits, envBlockLimits); + moveLegacy(blockLimitsOffset, envBlockLimitsOffset); + moveLegacy(entityLimits, envEntityLimits); + moveLegacy(entityLimitsOffset, envEntityLimitsOffset); + moveLegacy(entityGroupLimits, envEntityGroupLimits); + moveLegacy(entityGroupLimitsOffset, envEntityGroupLimitsOffset); + blockCounts = null; + blockLimits = null; + blockLimitsOffset = null; + entityLimits = null; + entityLimitsOffset = null; + entityGroupLimits = null; + entityGroupLimitsOffset = null; + } + + private static void moveLegacy(Map legacy, Map> target) { + if (legacy == null || legacy.isEmpty()) return; + target.computeIfAbsent(Environment.NORMAL, e -> newInner(legacy)).putAll(legacy); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static Map newInner(Map sample) { + Object first = sample.keySet().iterator().next(); + if (first instanceof EntityType) { + Map enumMap = new EnumMap<>(EntityType.class); + return (Map) enumMap; + } + return new HashMap<>(); + } + + /* ========================================================================= + * Block counts + * ========================================================================= */ + + public Map> getAllBlockCounts() { + migrateIfNeeded(); + if (envBlockCounts == null) envBlockCounts = new EnumMap<>(Environment.class); + return envBlockCounts; + } + + public Map getBlockCounts(Environment env) { + return getAllBlockCounts().computeIfAbsent(env, e -> new HashMap<>()); + } + + public int getBlockCount(Environment env, NamespacedKey key) { + return getBlockCounts(env).getOrDefault(key, 0); + } + + public int getBlockCount(NamespacedKey key) { + int total = 0; + for (Map m : getAllBlockCounts().values()) { + total += m.getOrDefault(key, 0); + } + return total; + } + + public void add(Environment env, NamespacedKey material) { + getBlockCounts(env).merge(material, 1, Integer::sum); + setChanged(); + } + + public void remove(Environment env, NamespacedKey material) { + Map m = getBlockCounts(env); + if (m.containsKey(material)) { + m.computeIfPresent(material, (k, count) -> count - 1 > 0 ? count - 1 : null); + setChanged(); + } + } + + public void clearAllBlockCounts() { + getAllBlockCounts().values().forEach(Map::clear); + setChanged(); + } + + /* ========================================================================= + * Block limits + * ========================================================================= */ + + public Map> getAllBlockLimits() { + migrateIfNeeded(); + if (envBlockLimits == null) envBlockLimits = new EnumMap<>(Environment.class); + return envBlockLimits; + } + + public Map getBlockLimits(Environment env) { + return getAllBlockLimits().computeIfAbsent(env, e -> new HashMap<>()); + } + + /** + * @return -1 if no env has a limit, otherwise the lowest limit defined across envs. + */ + public int getBlockLimit(Environment env, NamespacedKey key) { + return getBlockLimits(env).getOrDefault(key, -1); + } + + public boolean isBlockLimited(Environment env, NamespacedKey key) { + return getBlockLimits(env).containsKey(key); + } + + public void setBlockLimit(Environment env, NamespacedKey key, int limit) { + getBlockLimits(env).put(key, limit); + setChanged(); + } + + /** + * Set the same limit for every standard environment (overworld, nether, end). + * Used by 5-segment, env-unspecified permissions and global config defaults. + */ + public void setBlockLimitAllEnvs(NamespacedKey key, int limit) { + for (Environment env : Environment.values()) { + if (env == Environment.CUSTOM) continue; + setBlockLimit(env, key, limit); + } + } + + public void clearAllBlockLimits() { + getAllBlockLimits().values().forEach(Map::clear); + setChanged(); + } + + /* ========================================================================= + * Block limit offsets (env-agnostic in storage; offsets apply per env) + * ========================================================================= */ + + public Map> getAllBlockLimitsOffset() { + migrateIfNeeded(); + if (envBlockLimitsOffset == null) envBlockLimitsOffset = new EnumMap<>(Environment.class); + return envBlockLimitsOffset; + } + + public Map getBlockLimitsOffset(Environment env) { + return getAllBlockLimitsOffset().computeIfAbsent(env, e -> new HashMap<>()); + } + + public int getBlockLimitOffset(Environment env, NamespacedKey key) { + return getBlockLimitsOffset(env).getOrDefault(key, 0); + } + + public void setBlockLimitsOffset(Environment env, NamespacedKey key, int offset) { + getBlockLimitsOffset(env).put(key, offset); + setChanged(); + } + + /** + * Apply the same offset to every standard environment. Used by /offset which is env-agnostic. + */ + public void setBlockLimitsOffsetAllEnvs(NamespacedKey key, int offset) { + for (Environment env : Environment.values()) { + if (env == Environment.CUSTOM) continue; + setBlockLimitsOffset(env, key, offset); + } + } + + /* ========================================================================= + * Entity counts (persistent) + * ========================================================================= */ + + public Map> getAllEntityCounts() { + migrateIfNeeded(); + if (envEntityCounts == null) envEntityCounts = new EnumMap<>(Environment.class); + return envEntityCounts; + } + + public Map getEntityCounts(Environment env) { + return getAllEntityCounts().computeIfAbsent(env, e -> new EnumMap<>(EntityType.class)); + } + + public int getEntityCount(Environment env, EntityType type) { + return getEntityCounts(env).getOrDefault(type, 0); + } + + public int getEntityCount(EntityType type) { + int total = 0; + for (Map m : getAllEntityCounts().values()) { + total += m.getOrDefault(type, 0); + } + return total; + } + + public void incrementEntity(Environment env, EntityType type) { + getEntityCounts(env).merge(type, 1, Integer::sum); + setChanged(); + } + + public void decrementEntity(Environment env, EntityType type) { + Map m = getEntityCounts(env); + if (m.containsKey(type)) { + m.computeIfPresent(type, (k, c) -> c - 1 > 0 ? c - 1 : null); + setChanged(); + } + } + + public void clearAllEntityCounts() { + getAllEntityCounts().values().forEach(Map::clear); + setChanged(); + } + + /* ========================================================================= + * Entity limits + * ========================================================================= */ + + public Map> getAllEntityLimits() { + migrateIfNeeded(); + if (envEntityLimits == null) envEntityLimits = new EnumMap<>(Environment.class); + return envEntityLimits; + } + + public Map getEntityLimits(Environment env) { + return getAllEntityLimits().computeIfAbsent(env, e -> new EnumMap<>(EntityType.class)); + } + + public int getEntityLimit(Environment env, EntityType type) { + return getEntityLimits(env).getOrDefault(type, -1); + } + + public void setEntityLimit(Environment env, EntityType type, int limit) { + getEntityLimits(env).put(type, limit); + setChanged(); + } + + public void setEntityLimitAllEnvs(EntityType type, int limit) { + for (Environment env : Environment.values()) { + if (env == Environment.CUSTOM) continue; + setEntityLimit(env, type, limit); + } + } + + public void clearAllEntityLimits() { + getAllEntityLimits().values().forEach(Map::clear); + setChanged(); + } + + /* ========================================================================= + * Entity limit offsets + * ========================================================================= */ + + public Map> getAllEntityLimitsOffset() { + migrateIfNeeded(); + if (envEntityLimitsOffset == null) envEntityLimitsOffset = new EnumMap<>(Environment.class); + return envEntityLimitsOffset; + } + + public Map getEntityLimitsOffset(Environment env) { + return getAllEntityLimitsOffset().computeIfAbsent(env, e -> new EnumMap<>(EntityType.class)); + } + + public int getEntityLimitOffset(Environment env, EntityType type) { + return getEntityLimitsOffset(env).getOrDefault(type, 0); + } + + public void setEntityLimitsOffset(Environment env, EntityType type, int offset) { + getEntityLimitsOffset(env).put(type, offset); + setChanged(); + } + + public void setEntityLimitsOffsetAllEnvs(EntityType type, int offset) { + for (Environment env : Environment.values()) { + if (env == Environment.CUSTOM) continue; + setEntityLimitsOffset(env, type, offset); + } + } + + /* ========================================================================= + * Entity group limits + * ========================================================================= */ + + public Map> getAllEntityGroupLimits() { + migrateIfNeeded(); + if (envEntityGroupLimits == null) envEntityGroupLimits = new EnumMap<>(Environment.class); + return envEntityGroupLimits; + } + + public Map getEntityGroupLimits(Environment env) { + return getAllEntityGroupLimits().computeIfAbsent(env, e -> new HashMap<>()); + } + + public int getEntityGroupLimit(Environment env, String name) { + return getEntityGroupLimits(env).getOrDefault(name, -1); + } + + public void setEntityGroupLimit(Environment env, String name, int limit) { + getEntityGroupLimits(env).put(name, limit); + setChanged(); + } + + public void setEntityGroupLimitAllEnvs(String name, int limit) { + for (Environment env : Environment.values()) { + if (env == Environment.CUSTOM) continue; + setEntityGroupLimit(env, name, limit); + } + } + + public void clearAllEntityGroupLimits() { + getAllEntityGroupLimits().values().forEach(Map::clear); + setChanged(); + } + + /* ========================================================================= + * Entity group limit offsets + * ========================================================================= */ + + public Map> getAllEntityGroupLimitsOffset() { + migrateIfNeeded(); + if (envEntityGroupLimitsOffset == null) envEntityGroupLimitsOffset = new EnumMap<>(Environment.class); + return envEntityGroupLimitsOffset; + } + + public Map getEntityGroupLimitsOffset(Environment env) { + return getAllEntityGroupLimitsOffset().computeIfAbsent(env, e -> new HashMap<>()); + } + + public int getEntityGroupLimitOffset(Environment env, String name) { + return getEntityGroupLimitsOffset(env).getOrDefault(name, 0); + } + + public void setEntityGroupLimitsOffset(Environment env, String name, int offset) { + getEntityGroupLimitsOffset(env).put(name, offset); + setChanged(); + } + + public void setEntityGroupLimitsOffsetAllEnvs(String name, int offset) { + for (Environment env : Environment.values()) { + if (env == Environment.CUSTOM) continue; + setEntityGroupLimitsOffset(env, name, offset); + } + } + + /* ========================================================================= + * "At limit" helpers + * ========================================================================= */ + + public boolean isAtLimit(Environment env, NamespacedKey key) { + if (!isBlockLimited(env, key)) return false; + return getBlockCount(env, key) >= getBlockLimit(env, key) + getBlockLimitOffset(env, key); + } + + public boolean isAtLimit(Environment env, NamespacedKey key, int limit) { + return getBlockCount(env, key) >= limit + getBlockLimitOffset(env, key); + } + + /* ========================================================================= + * Misc + * ========================================================================= */ + + public String getGameMode() { + return gameMode; + } + + public void setGameMode(String gameMode) { + this.gameMode = gameMode; + setChanged(); + } + + public boolean isGameMode(String gameMode) { + return getGameMode().equals(gameMode); + } + + @Override + public String getUniqueId() { + return uniqueId; + } + + @Override + public void setUniqueId(String uniqueId) { + this.uniqueId = uniqueId; + setChanged(); + } + + public boolean isChanged() { + return changed; + } + + public void setChanged() { + this.changed = true; + } + + public void setChanged(boolean changed) { + this.changed = changed; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index e73bfa9..358ea02 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,136 +1,170 @@ -# Game Modes covered by limits -gamemodes: -- AcidIsland -- BSkyBlock -- CaveBlock -- AOneBlock - -# Ignore this island's center block. For most worlds, this is bedrock, but for AOneBlock it is -# the magic block, so ignoring it from limits makes sense. -ignore-center-block: true - -# Permissions -# Island owners can be given permissions that override all general settings -# Format is GAME-MODE-NAME.island.limit.MATERIAL.LIMIT -# example: bskyblock.island.limit.hopper.10 -# permission activates when player logs in. -# -# Cooldown for player recount command in seconds -cooldown: 120 - -# Log limits on join (to console) -log-limits-on-join: true - -# Use async checks for snowmen and iron golums. Set to false if you see problems. -async-golums: true - -# General block limiting -# Use this section to limit how many blocks can be added to an island. -# 0 means the item will be blocked from placement completely. -# These limits apply to every game mode world -# The following general terms can be used: STAIRS, DOORS, SIGNS, LOGS, SNOW, LEAVES, SHULKER_BOXES, -# BANNERS, RAILS, ICE, BUTTONS, CANDLE_CAKES, CAMPFIRES, SLABS, STONE_BRICKS, -blocklimits: - HOPPER: 10 -# This section is for world-specific limits and overrides the general limit -# Specify each world you want to limit individually (including nether and end worlds) -# If these worlds are not covered by the game modes above, nothing will happen -worlds: - AcidIsland_world: - HOPPER: 11 - -# Default entity limits within a player's island space (protected area and to island limit). -# A limit of 5 will allow up to 5 entities in over world, 5 in nether and 5 in the end. -# Affects all types of creature spawning. Also includes entities like MINECARTS. -# Note: Only the first 49 limited blocks and entities are shown in the limits GUI. -entitylimits: - ENDERMAN: 5 - CHICKEN: 10 - -# Entity Groups -# Name the group anything you like -# Select an icon, which is a Bukkit Material -# Select the limit for the total group -# List the entities in the group using Bukkit EntityTypes -entitygrouplimits: - Monsters: - icon: ROTTEN_FLESH - limit: 250 - entities: - - SKELETON - - SILVERFISH - - STRAY - - ZOMBIE_VILLAGER - - WITHER - - WARDEN - - BLAZE - - DROWNED - - BREEZE - - ZOMBIFIED_PIGLIN - - EVOKER - - PILLAGER - - HUSK - - CREEPER - - VINDICATOR - - ZOMBIE - - ENDERMAN - - ELDER_GUARDIAN - - WITHER_SKELETON - - CAVE_SPIDER - - GUARDIAN - - RAVAGER - - PIGLIN - - BOGGED - - WITCH - - ENDERMITE - - ZOGLIN - - PIGLIN_BRUTE - - ILLUSIONER - - SPIDER - - VEX - Animals: - icon: SADDLE - limit: 200 - entities: - - SHEEP - - AXOLOTL - - DONKEY - - MOOSHROOM - - TRADER_LLAMA - - BEE - - HORSE - - ZOMBIE_HORSE - - PIG - - PARROT - - CHICKEN - - RABBIT - - SNIFFER - - FROG - - GOAT - - PANDA - - CAMEL - - STRIDER - - TURTLE - - CAT - - SKELETON_HORSE - - COW - - LLAMA - - ARMADILLO - - HOGLIN - - POLAR_BEAR - - WOLF - - MULE - - OCELOT - - FOX - - DOLPHIN - - COD - - PUFFERFISH - - TADPOLE - - SQUID - - SALMON - - TROPICAL_FISH - - GLOW_SQUID - - - - +# Game Modes covered by limits +gamemodes: +- AcidIsland +- BSkyBlock +- CaveBlock +- AOneBlock + +# Ignore this island's center block. For most worlds, this is bedrock, but for AOneBlock it is +# the magic block, so ignoring it from limits makes sense. +ignore-center-block: true + +# Permissions +# Island owners can be given permissions that override all general settings. +# +# Two formats are supported: +# 1. .island.limit.. — applied to every environment. +# e.g. bskyblock.island.limit.hopper.20 +# 2. .island.limit... — applied to one environment only. +# e.g. bskyblock.island.limit.nether.hopper.5 +# is one of: overworld, nether, end +# +# Permissions activate when the island owner logs in. +# +# Cooldown for player recount command in seconds +cooldown: 120 + +# Log limits on join (to console) +log-limits-on-join: true + +# Use async checks for snowmen and iron golums. Set to false if you see problems. +async-golums: true + +# General block limiting +# Use this section to limit how many blocks can be added to an island. +# 0 means the item will be blocked from placement completely. +# These limits apply independently in every environment (overworld, nether, end). +# A HOPPER limit of 10 means the player can place up to 10 hoppers in each of overworld, +# nether, and end — 30 hoppers total across the island. +# Use blocklimits-nether / blocklimits-end below to override per environment. +# The following general terms can be used: STAIRS, DOORS, SIGNS, LOGS, SNOW, LEAVES, +# SHULKER_BOXES, BANNERS, RAILS, ICE, BUTTONS, CANDLE_CAKES, CAMPFIRES, SLABS, STONE_BRICKS +blocklimits: + HOPPER: 10 + +# Override block limits in the nether for this game mode. +# Uncomment and add entries to set nether-only limits. +#blocklimits-nether: +# HOPPER: 5 + +# Override block limits in the end for this game mode. +#blocklimits-end: +# HOPPER: 5 + +# This section is for world-named limits and overrides any environment-default limit +# above for that specific world. Specify each world you want to limit individually +# (including nether and end worlds). If these worlds are not covered by the game modes +# above, nothing will happen. +worlds: + AcidIsland_world: + HOPPER: 11 + +# Default entity limits within a player's island space (protected area and to island limit). +# Limits apply independently per environment, the same way blocklimits do. +# Affects all types of creature spawning. Also includes entities like MINECARTS. +# Note: Only the first 49 limited blocks and entities are shown in the limits GUI. +entitylimits: + ENDERMAN: 5 + CHICKEN: 10 + +# Override entity limits in the nether. +#entitylimits-nether: +# PIGLIN: 20 +# HOGLIN: 5 + +# Override entity limits in the end. +#entitylimits-end: +# ENDERMAN: 50 + +# Entity Groups +# Name the group anything you like +# Select an icon, which is a Bukkit Material +# Select the limit for the total group +# List the entities in the group using Bukkit EntityTypes +# Group limits apply independently per environment. +entitygrouplimits: + Monsters: + icon: ROTTEN_FLESH + limit: 250 + entities: + - SKELETON + - SILVERFISH + - STRAY + - ZOMBIE_VILLAGER + - WITHER + - WARDEN + - BLAZE + - DROWNED + - BREEZE + - ZOMBIFIED_PIGLIN + - EVOKER + - PILLAGER + - HUSK + - CREEPER + - VINDICATOR + - ZOMBIE + - ENDERMAN + - ELDER_GUARDIAN + - WITHER_SKELETON + - CAVE_SPIDER + - GUARDIAN + - RAVAGER + - PIGLIN + - BOGGED + - WITCH + - ENDERMITE + - ZOGLIN + - PIGLIN_BRUTE + - ILLUSIONER + - SPIDER + - VEX + Animals: + icon: SADDLE + limit: 200 + entities: + - SHEEP + - AXOLOTL + - DONKEY + - MOOSHROOM + - TRADER_LLAMA + - BEE + - HORSE + - ZOMBIE_HORSE + - PIG + - PARROT + - CHICKEN + - RABBIT + - SNIFFER + - FROG + - GOAT + - PANDA + - CAMEL + - STRIDER + - TURTLE + - CAT + - SKELETON_HORSE + - COW + - LLAMA + - ARMADILLO + - HOGLIN + - POLAR_BEAR + - WOLF + - MULE + - OCELOT + - FOX + - DOLPHIN + - COD + - PUFFERFISH + - TADPOLE + - SQUID + - SALMON + - TROPICAL_FISH + - GLOW_SQUID + +# Override an entity group's limit per environment. The group must already be defined +# in entitygrouplimits above; only the numeric limit can be overridden, not the icon +# or member set. +#entitygrouplimits-nether: +# Monsters: 100 +#entitygrouplimits-end: +# Monsters: 50 diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index bab9a97..e069d1e 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] omezen na [number]!" + hit-limit: "[material] omezen na [number]!" entity-limits: - hit-limit: "&cSpawnování [entity] omezeno na [number]!" + hit-limit: "Spawnování [entity] omezeno na [number]!" limits: panel-title: Omezení ostrovů admin: @@ -13,51 +13,55 @@ admin: calc: parameters: "" description: přepočítat omezení ostrova hráče - finished: "&aPřepočítání ostrova úspěšně dokončeno!" + finished: "Přepočítání ostrova úspěšně dokončeno!" offset: - unknown: "&c Neznámý materiál nebo entita [name]." + unknown: " Neznámý materiál nebo entita [name]." + description: "umožňuje spravovat limity offsetů pro materiály a entity" main: description: umožňuje spravovat limity offsetů pro materiály a entity set: parameters: " <číslo>" description: nastaví nový offset pro limit materiálu nebo entity - success: "&a Mezní posun pro [name] je nastaven na [number]." - same: "&c Mezní posun pro [name] je již [number]." + success: " Mezní posun pro [name] je nastaven na [number]." + same: " Mezní posun pro [name] je již [number]." add: parameters: " <číslo>" description: přidá offset pro limit materiálu nebo entity - success: "&a Posun limitu pro [name] se zvýší na [number]." + success: " Posun limitu pro [name] se zvýší na [number]." remove: parameters: " <číslo>" description: snižuje offset pro limit materiálu nebo entity - success: "&a Posun limitu pro [name] se sníží na [number]." + success: " Posun limitu pro [name] se sníží na [number]." reset: parameters: " " description: odstraní offset pro materiál nebo entitu - success: "&a Mezní posun pro [jméno] je nastaven na 0." + success: " Mezní posun pro [jméno] je nastaven na 0." view: parameters: " " description: zobrazuje posun pro materiál nebo entitu - message: "&odsazení [name] je nastaveno na [number]." + message: "dsazení [name] je nastaveno na [number]." island: limits: description: ukázat omezení tvého ostrova - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c V tomto světě nejsou stanovena žádná omezení" + no-limits: " V tomto světě nejsou stanovena žádná omezení" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Přesvět" + env-nether: "Nether" + env-end: "Konec" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Tento ostrov nemá vlastníka" - not-on-island: "&c Toto umístění nemá nastavena omezení." + no-owner: " Tento ostrov nemá vlastníka" + not-on-island: " Toto umístění nemá nastavena omezení." recount: description: přepočítá omezení tvého ostrova - now-recounting: "&b Nyní vyprávění. Může to chvíli trvat, čekejte prosím..." - in-progress: "&c Probíhá obnovení ostrova. Čekejte prosím..." - time-out: "&c Časový limit při přepočítávání. Je ostrov opravdu velký?" + now-recounting: " Nyní vyprávění. Může to chvíli trvat, čekejte prosím..." + in-progress: " Probíhá obnovení ostrova. Čekejte prosím..." + time-out: " Časový limit při přepočítávání. Je ostrov opravdu velký?" diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index 5400e29..a741c85 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] limitiert auf [number]!" + hit-limit: "[material] limitiert auf [number]!" entity-limits: - hit-limit: "&c[entity] spawning limitiert auf [number]!" + hit-limit: "[entity] spawning limitiert auf [number]!" limits: panel-title: Insel Limitierungen admin: @@ -13,52 +13,56 @@ admin: calc: parameters: "" description: Berechne die Insel Limitierungen für den Spieler neu - finished: "&aInselberechnung erfolgreich abgeschlossen!" + finished: "Inselberechnung erfolgreich abgeschlossen!" offset: - unknown: "&c Unbekanntes Material oder Entität [name]." + unknown: " Unbekanntes Material oder Entität [name]." + description: "ermöglicht die Verwaltung von Grenzwertverschiebungen für Materialien und Entitäten" main: description: ermöglicht die Verwaltung von Grenzwertverschiebungen für Materialien und Entitäten set: parameters: " " description: legt einen neuen Offset für Material- oder Entity-Grenzwert fest - success: "&a Der Grenzoffset für [name] ist auf [number] eingestellt." - same: "&c Der Grenzoffset für [name] ist bereits [number]." + success: " Der Grenzoffset für [name] ist auf [number] eingestellt." + same: " Der Grenzoffset für [name] ist bereits [number]." add: parameters: " " description: fügt einen Offset für Material- oder Entitätslimit hinzu - success: "&a Der Limit-Offset für [name] wird bis [number] erhöht." + success: " Der Limit-Offset für [name] wird bis [number] erhöht." remove: parameters: " " description: reduziert den Offset für Material- oder Entitätslimit - success: "&a Der Grenzoffset für [name] wird auf [number] reduziert." + success: " Der Grenzoffset für [name] wird auf [number] reduziert." reset: parameters: " " description: Entfernt den Offset für Material oder Entität - success: "&a Der Grenzoffset für [name] wird auf 0 gesetzt." + success: " Der Grenzoffset für [name] wird auf 0 gesetzt." view: parameters: " " description: zeigt den Offset für Material oder Entität an - message: "&a [name]-Offset ist auf [number] gesetzt." + message: " [name]-Offset ist auf [number] gesetzt." island: limits: description: Zeige deine Insel Limitierungen - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c Es gibt keine Grenzen in dieser Welt" + no-limits: " Es gibt keine Grenzen in dieser Welt" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Oberwelt" + env-nether: "Nether" + env-end: "Ende" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Diese Insel hat keinen Besitzer" - not-on-island: "&c Für diesen Standort sind keine Beschränkungen festgelegt." + no-owner: " Diese Insel hat keinen Besitzer" + not-on-island: " Für diesen Standort sind keine Beschränkungen festgelegt." recount: description: Zählt die Limitierungen für deine Insel auf - now-recounting: "&b Ich erzähle jetzt. Dies kann eine Weile dauern, bitte warten..." - in-progress: "&c Inselrückmeldung läuft. Warten Sie mal..." - time-out: "&c Zeitüberschreitung beim Erzählen. Ist die Insel wirklich so groß?" + now-recounting: " Ich erzähle jetzt. Dies kann eine Weile dauern, bitte warten..." + in-progress: " Inselrückmeldung läuft. Warten Sie mal..." + time-out: " Zeitüberschreitung beim Erzählen. Ist die Insel wirklich so groß?" diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 96c73a1..477425b 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -4,9 +4,9 @@ ########################################################################################### block-limits: - hit-limit: "&c[material] limited to [number]!" + hit-limit: "[material] limited to [number]!" entity-limits: - hit-limit: "&c[entity] spawning limited to [number]!" + hit-limit: "[entity] spawning limited to [number]!" limits: panel-title: "Island limits" @@ -18,51 +18,52 @@ admin: calc: parameters: "" description: "recalculate the island limits for player" - finished: "&a Island recalc finished successfully!" + finished: " Island recalc finished successfully!" offset: - unknown: "&c Unknown material or entity [name]." + unknown: " Unknown material or entity [name]." description: "allows to manage limits offsets for materials and entities" set: parameters: " " description: "sets new offset for material or entity limit" - success: "&a Limit offset for [name] is set to [number]." - same: "&c Limit offset for [name] is already [number]." + success: " Limit offset for [name] is set to [number]." + same: " Limit offset for [name] is already [number]." add: parameters: " " description: "adds offset for material or entity limit" - success: "&a Limit offset for [name] is increased till [number]." + success: " Limit offset for [name] is increased till [number]." remove: parameters: " " description: "reduces offset for material or entity limit" - success: "&a Limit offset for [name] is reduced till [number]." + success: " Limit offset for [name] is reduced till [number]." reset: parameters: " " description: "removes offset for material or entity" - success: "&a Limit offset for [name] is set to 0." + success: " Limit offset for [name] is set to 0." view: parameters: " " description: "displays offset for material or entity" - message: "&a [name] offset is set to [number]." + message: " [name] offset is set to [number]." island: limits: description: "show your island limits" - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c No limits set in this world" + no-limits: " No limits set in this world" panel: - title-syntax: '[title] [sort]' - entity-group-name-syntax: '[name]' + title-syntax: '[title] [env]' + entity-group-name-syntax: '[name]' entity-name-syntax: '[name]' block-name-syntax: '[name]' - A2Z: "a > z" - Z2A: "z > a" + env-overworld: "Overworld" + env-nether: "Nether" + env-end: "End" errors: - no-owner: "&c That island has no owner" - not-on-island: "&c This location does not have limits set." + no-owner: " That island has no owner" + not-on-island: " This location does not have limits set." recount: description: "recounts limits for your island" - now-recounting: "&b Now recounting. This could take a while, please wait..." - in-progress: "&c Island recound is in progress. Please wait..." - time-out: "&c Time out when recounting. Is the island really big?" + now-recounting: " Now recounting. This could take a while, please wait..." + in-progress: " Island recound is in progress. Please wait..." + time-out: " Time out when recounting. Is the island really big?" diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 89a4ce4..1af7ab7 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] limitado a [number]!" + hit-limit: "[material] limitado a [number]!" entity-limits: - hit-limit: "¡Aparición de &c[entity] limitada a [number]!" + hit-limit: "¡Aparición de [entity] limitada a [number]!" limits: panel-title: Límites de la isla admin: @@ -13,9 +13,10 @@ admin: calc: parameters: "" description: recalcular los límites de la isla para el jugador - finished: "&a ¡El recálculo de la Isla terminó con éxito!" + finished: " ¡El recálculo de la Isla terminó con éxito!" offset: - unknown: "&c Material o entidad desconocida [name]." + unknown: " Material o entidad desconocida [name]." + description: "permite gestionar compensaciones de límites para materiales y entidades" main: description: permite gestionar compensaciones de límites para materiales y entidades @@ -23,43 +24,46 @@ admin: parameters: " " description: establece una nueva compensación para el límite de material o entidad - success: "&a El desplazamiento límite para [name] está establecido en [number]." - same: "&c El límite de compensación para [name] ya es [number]." + success: " El desplazamiento límite para [name] está establecido en [number]." + same: " El límite de compensación para [name] ya es [number]." add: parameters: " " description: agrega compensación por límite de material o entidad - success: "&a El límite de compensación para [name] aumenta hasta [number]." + success: " El límite de compensación para [name] aumenta hasta [number]." remove: parameters: " " description: reduce la compensación por límite de material o entidad - success: "&a La compensación límite para [name] se reduce hasta [number]." + success: " La compensación límite para [name] se reduce hasta [number]." reset: parameters: " " description: elimina la compensación para material o entidad - success: "&a El desplazamiento límite para [number] está establecido en 0." + success: " El desplazamiento límite para [number] está establecido en 0." view: parameters: " " description: muestra el desplazamiento para material o entidad - message: "& desplazamiento de [name] se establece en [number]." + message: " desplazamiento de [name] se establece en [number]." island: limits: description: muestra los límites de tu isla - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c No hay límites establecidos en este mundo." + no-limits: " No hay límites establecidos en este mundo." panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Superficie" + env-nether: "Nether" + env-end: "El End" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Esa isla no tiene dueño" - not-on-island: "&c Esta ubicación no tiene límites establecidos." + no-owner: " Esa isla no tiene dueño" + not-on-island: " Esta ubicación no tiene límites establecidos." recount: description: cuenta los límites para tu isla - now-recounting: "&b Ahora contando. Esto podría tomar un tiempo, por favor espere..." - in-progress: "&c El recuento de la isla está en progreso. Espere por favor..." - time-out: "&c Ha pasado demasiado tiempo contando. ¿La isla es realmente grande?" + now-recounting: " Ahora contando. Esto podría tomar un tiempo, por favor espere..." + in-progress: " El recuento de la isla está en progreso. Espere por favor..." + time-out: " Ha pasado demasiado tiempo contando. ¿La isla es realmente grande?" diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index 6c1891f..25ebb40 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] limité à [number]!" + hit-limit: "[material] limité à [number]!" entity-limits: - hit-limit: "&c[entity] spawning limited to [number]!" + hit-limit: "[entity] spawning limited to [number]!" limits: panel-title: Limites de l'île admin: @@ -13,54 +13,58 @@ admin: calc: parameters: "" description: recalcule les limites de l'île pour le joueur - finished: "&a Recomptage terminé avec succès!" + finished: " Recomptage terminé avec succès!" offset: - unknown: "&c Matériau ou entité inconnu [name]." + unknown: " Matériau ou entité inconnu [name]." + description: "permet de gérer les décalages de limites pour les matériaux et les entités" main: description: permet de gérer les décalages de limites pour les matériaux et les entités set: parameters: " " description: définit un nouveau décalage pour la limite de matériau ou d'entité - success: "&a Le décalage limite pour [name] est défini sur [number]." - same: "&c Le décalage limite pour [name] est déjà [number]." + success: " Le décalage limite pour [name] est défini sur [number]." + same: " Le décalage limite pour [name] est déjà [number]." add: parameters: " " description: ajoute un décalage pour la limite de matériau ou d'entité - success: "&a Le décalage limite pour [name] est augmenté jusqu'à [number]." + success: " Le décalage limite pour [name] est augmenté jusqu'à [number]." remove: parameters: " " description: réduit le décalage pour la limite de matériau ou d'entité - success: "&a Le décalage limite pour [name] est réduit jusqu'à [number]." + success: " Le décalage limite pour [name] est réduit jusqu'à [number]." reset: parameters: " " description: supprime le décalage pour le matériau ou l'entité - success: "&a Le décalage limite pour [name] est défini sur 0." + success: " Le décalage limite pour [name] est défini sur 0." view: parameters: " " description: affiche le décalage pour le matériau ou l'entité - message: "&a [name] le décalage est défini sur [number]." + message: " [name] le décalage est défini sur [number]." island: limits: description: affichez les limites de votre île - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c Aucune limite n'est fixée dans ce monde" + no-limits: " Aucune limite n'est fixée dans ce monde" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Surface" + env-nether: "Nether" + env-end: "l'End" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Cette île n'a pas de propriétaire" - not-on-island: "&c Cet emplacement n'a pas de limites définies." + no-owner: " Cette île n'a pas de propriétaire" + not-on-island: " Cet emplacement n'a pas de limites définies." recount: description: recompte les limites de votre île - now-recounting: "&b Recomptage en cours. Cela peut prendre un certain temps, + now-recounting: " Recomptage en cours. Cela peut prendre un certain temps, veuillez patienter..." - in-progress: "&c Le recomptage de l'île est en cours. Veuillez patienter s'il + in-progress: " Le recomptage de l'île est en cours. Veuillez patienter s'il vous plaît..." - time-out: "&c Time out lors du recomptage. L'île est-elle vraiment grande?" + time-out: " Time out lors du recomptage. L'île est-elle vraiment grande?" diff --git a/src/main/resources/locales/hr.yml b/src/main/resources/locales/hr.yml index 5c10936..9d571e8 100644 --- a/src/main/resources/locales/hr.yml +++ b/src/main/resources/locales/hr.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] ograničen na [number]!" + hit-limit: "[material] ograničen na [number]!" entity-limits: - hit-limit: "&c[entity] stvaranje ograničeno na [number]!" + hit-limit: "[entity] stvaranje ograničeno na [number]!" limits: panel-title: Granice otoka admin: @@ -13,51 +13,55 @@ admin: calc: parameters: "" description: ponovno izračunati ograničenja otoka za igrača - finished: "&a Recalc otoka uspješno završen!" + finished: " Recalc otoka uspješno završen!" offset: - unknown: "&c Nepoznati materijal ili entitet [name]." + unknown: " Nepoznati materijal ili entitet [name]." + description: "omogućuje upravljanje pomacima ograničenja za materijale i entitete" main: description: omogućuje upravljanje pomacima ograničenja za materijale i entitete set: parameters: " " description: postavlja novi pomak za ograničenje materijala ili entiteta - success: "&a Ograničenje pomaka za [name] postavljeno je na [number]." - same: "&c Ograničenje pomaka za [name] već je [number]." + success: " Ograničenje pomaka za [name] postavljeno je na [number]." + same: " Ograničenje pomaka za [name] već je [number]." add: parameters: " " description: dodaje pomak za ograničenje materijala ili entiteta - success: "&a Ograničenje pomaka za [name] povećava se do [number]." + success: " Ograničenje pomaka za [name] povećava se do [number]." remove: parameters: " " description: smanjuje pomak za ograničenje materijala ili entiteta - success: "&a Ograničenje pomaka za [name] smanjeno je do [number]." + success: " Ograničenje pomaka za [name] smanjeno je do [number]." reset: parameters: " " description: uklanja pomak za materijal ili entitet - success: "&a Ograničenje pomaka za [name] postavljeno je na 0." + success: " Ograničenje pomaka za [name] postavljeno je na 0." view: parameters: " " description: prikazuje pomak za materijal ili entitet - message: "&pomak [name] postavljen je na [number]." + message: "pomak [name] postavljen je na [number]." island: limits: description: pokazati granice vašeg otoka max-color: i c - regular-color: "&a" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c U ovom svijetu nema ograničenja" + no-limits: " U ovom svijetu nema ograničenja" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Nadsvijet" + env-nether: "Nether" + env-end: "Kraj" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Taj otok nema vlasnika" - not-on-island: "&c Ova lokacija nema postavljena ograničenja." + no-owner: " Taj otok nema vlasnika" + not-on-island: " Ova lokacija nema postavljena ograničenja." recount: description: preračunava ograničenja za vaš otok - now-recounting: "&b Sada prepričavam. Ovo bi moglo potrajati, pričekajte..." - in-progress: "&c Otok je u tijeku. Molimo pričekajte..." - time-out: "&c Time out kod prepričavanja. Je li otok stvarno velik?" + now-recounting: " Sada prepričavam. Ovo bi moglo potrajati, pričekajte..." + in-progress: " Otok je u tijeku. Molimo pričekajte..." + time-out: " Time out kod prepričavanja. Je li otok stvarno velik?" diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index 9b3a760..f7d2840 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] limitálva ennyire [number]!" + hit-limit: "[material] limitálva ennyire [number]!" entity-limits: - hit-limit: "&c[entity] idézés limitálva ennyire [number]!" + hit-limit: "[entity] idézés limitálva ennyire [number]!" limits: panel-title: Sziget limitek admin: @@ -13,51 +13,55 @@ admin: calc: parameters: "" description: Egy játékos sziget limitjének újraszámolása - finished: "&aA Sziget újraszámolás sikeresen befejeződött!" + finished: "A Sziget újraszámolás sikeresen befejeződött!" offset: - unknown: "&c Ismeretlen anyag vagy entitás [name]." + unknown: " Ismeretlen anyag vagy entitás [name]." + description: "lehetővé teszi az anyagok és entitások limiteltolásainak kezelését" main: description: lehetővé teszi az anyagok és entitások limiteltolásainak kezelését set: parameters: " " description: új eltolást állít be az anyag- vagy entitáskorláthoz - success: "&a A [name] határeltolódása [number] értékre van állítva." - same: "&c A [name] korláteltolása már [number]." + success: " A [name] határeltolódása [number] értékre van állítva." + same: " A [name] korláteltolása már [number]." add: parameters: " " description: eltolást ad hozzá az anyag- vagy entitáskorláthoz - success: "&a A [name] határeltolódása a [number] értékig nő." + success: " A [name] határeltolódása a [number] értékig nő." remove: parameters: " " description: csökkenti az anyag- vagy entitáskorlát ellentételezését - success: "&a A [name] limiteltolása [number] értékre csökken." + success: " A [name] limiteltolása [number] értékre csökken." reset: parameters: " " description: eltávolítja az anyag vagy entitás eltolását - success: "&a A [name] határeltolódása 0-ra van állítva." + success: " A [name] határeltolódása 0-ra van állítva." view: parameters: " " description: anyag vagy entitás eltolását jeleníti meg - message: "&a [name] eltolás értéke [number]." + message: " [name] eltolás értéke [number]." island: limits: description: Sziget limitek megtekintése - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c Nincsenek korlátok ezen a világon" + no-limits: " Nincsenek korlátok ezen a világon" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Felvilág" + env-nether: "Nether" + env-end: "Vég" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Annak a szigetnek nincs gazdája" - not-on-island: "&c Ennek a helynek nincs korlátja beállítva." + no-owner: " Annak a szigetnek nincs gazdája" + not-on-island: " Ennek a helynek nincs korlátja beállítva." recount: description: Limitek újraszámolása a szigeteden - now-recounting: "&b Most mesélünk. Ez eltarthat egy ideig, kérem, várjon..." - in-progress: "&c Sziget visszaállítása folyamatban van. Kérem, várjon..." - time-out: "&c Időtúllépés az újraszámláláskor. Tényleg nagy a sziget?" + now-recounting: " Most mesélünk. Ez eltarthat egy ideig, kérem, várjon..." + in-progress: " Sziget visszaállítása folyamatban van. Kérem, várjon..." + time-out: " Időtúllépés az újraszámláláskor. Tényleg nagy a sziget?" diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml index c365d9c..b9c6822 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] dibatasi sampai [number]!" + hit-limit: "[material] dibatasi sampai [number]!" entity-limits: - hit-limit: "&c[entity] spawning dibatasi sampai [number]!" + hit-limit: "[entity] spawning dibatasi sampai [number]!" limits: panel-title: Batasan Pulau admin: @@ -13,53 +13,57 @@ admin: calc: parameters: "" description: menghitung ulang batasan pulau untuk pemain - finished: "&aPenghitungan ulang pulau selesai tanpa masalah!" + finished: "Penghitungan ulang pulau selesai tanpa masalah!" offset: - unknown: "&c Material atau entitas tidak dikenal [name]." + unknown: " Material atau entitas tidak dikenal [name]." + description: "memungkinkan untuk mengelola batas offset untuk material dan entitas" main: description: memungkinkan untuk mengelola batas offset untuk material dan entitas set: parameters: " " description: menetapkan offset baru untuk batasan material atau entitas - success: "&a Batas offset untuk [name] ditetapkan ke [number]." - same: "&a Batas offset untuk [name] ditetapkan ke [number]." + success: " Batas offset untuk [name] ditetapkan ke [number]." + same: " Batas offset untuk [name] ditetapkan ke [number]." add: parameters: " " description: menambahkan offset untuk batasan material atau entitas - success: "&a Batas offset untuk [name] ditingkatkan hingga [number]." + success: " Batas offset untuk [name] ditingkatkan hingga [number]." remove: parameters: " " description: mengurangi offset untuk batas material atau entitas - success: "&a Batas offset untuk [name] dikurangi hingga [number]." + success: " Batas offset untuk [name] dikurangi hingga [number]." reset: parameters: " " description: menghapus offset untuk material atau entitas - success: "&a Batas offset untuk [name] ditetapkan ke 0." + success: " Batas offset untuk [name] ditetapkan ke 0." view: parameters: " " description: menampilkan offset untuk material atau entitas - message: "&a [name] offset diatur ke [number]." + message: " [name] offset diatur ke [number]." island: limits: description: menampilkan batasan pulau kamu - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c Tidak ada batasan yang ditetapkan di dunia ini" + no-limits: " Tidak ada batasan yang ditetapkan di dunia ini" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Dunia Atas" + env-nether: "Nether" + env-end: "End" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Pulau itu tidak memiliki pemilik" - not-on-island: "&c Lokasi ini tidak memiliki batasan yang ditetapkan." + no-owner: " Pulau itu tidak memiliki pemilik" + not-on-island: " Lokasi ini tidak memiliki batasan yang ditetapkan." recount: description: menghitung ulang batasan untuk pulau kamu - now-recounting: "&b Menghitung ulang. Membutuhkan waktu beberapa saat, silahkan + now-recounting: " Menghitung ulang. Membutuhkan waktu beberapa saat, silahkan tunggu..." - in-progress: "&c Perhitungan pulau sedang diproses. Silahkan tunggu..." - time-out: "&c Gagal menghitung ulang. Apakah pulau terlalu besar?" + in-progress: " Perhitungan pulau sedang diproses. Silahkan tunggu..." + time-out: " Gagal menghitung ulang. Apakah pulau terlalu besar?" diff --git a/src/main/resources/locales/it.yml b/src/main/resources/locales/it.yml index 53fb2ca..af5f909 100644 --- a/src/main/resources/locales/it.yml +++ b/src/main/resources/locales/it.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] limitato a [number]!" + hit-limit: "[material] limitato a [number]!" entity-limits: - hit-limit: "&c[entity] la generazione è limitata a [number]!" + hit-limit: "[entity] la generazione è limitata a [number]!" limits: panel-title: Limiti dell'isola admin: @@ -13,51 +13,55 @@ admin: calc: parameters: "" description: ricalcola i limiti dell'isola per il giocatore - finished: "&a Il ricalcolo dell'isola è stato completato con successo!" + finished: " Il ricalcolo dell'isola è stato completato con successo!" offset: - unknown: "&c Materiale o entità sconosciuta [name]." + unknown: " Materiale o entità sconosciuta [name]." + description: "consente di gestire gli offset dei limiti per materiali ed entità" main: description: consente di gestire gli offset dei limiti per materiali ed entità set: parameters: " " description: imposta un nuovo offset per il limite di materiale o entità - success: "&a Il limite di offset per [name] è impostato su [number]." - same: "&c Il limite di offset per [name] è già [number]." + success: " Il limite di offset per [name] è impostato su [number]." + same: " Il limite di offset per [name] è già [number]." add: parameters: " " description: aggiunge offset per limite di materiale o entità - success: "&a Il limite di offset per [name] è aumentato fino a [number]." + success: " Il limite di offset per [name] è aumentato fino a [number]." remove: parameters: " " description: riduce l'offset per il limite di materiale o entità - success: "&a Il limite di offset per [name] è ridotto fino a [number]." + success: " Il limite di offset per [name] è ridotto fino a [number]." reset: parameters: " " description: rimuove l'offset per materiale o entità - success: "&a Il limite di offset per [name] è impostato su 0." + success: " Il limite di offset per [name] è impostato su 0." view: parameters: " " description: visualizza l'offset per materiale o entità - message: "&a [name] offset è impostato su [number]." + message: " [name] offset è impostato su [number]." island: limits: description: mostra i limiti della tua isola - max-color: "&C" - regular-color: "&UN" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c Non ci sono limiti in questo mondo" + no-limits: " Non ci sono limiti in questo mondo" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Sopramondo" + env-nether: "Nether" + env-end: "L'End" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Quell'isola non ha proprietario" - not-on-island: "&c Questa posizione non ha limiti impostati." + no-owner: " Quell'isola non ha proprietario" + not-on-island: " Questa posizione non ha limiti impostati." recount: description: racconta i limiti per la tua isola - now-recounting: "&b Ora sto raccontando. Potrebbe volerci un po', attendi..." - in-progress: "&c Il conteggio dell'isola è in corso. Attendi..." - time-out: "&c Time out quando si racconta. L'isola è davvero grande?" + now-recounting: " Ora sto raccontando. Potrebbe volerci un po', attendi..." + in-progress: " Il conteggio dell'isola è in corso. Attendi..." + time-out: " Time out quando si racconta. L'isola è davvero grande?" diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 2b73b34..700d3e5 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -1,7 +1,7 @@ block-limits: - hit-limit: '&c[material]は[number]に制限されています!' + hit-limit: '[material]は[number]に制限されています!' entity-limits: - hit-limit: '&c[entity]の生成は[number]に制限されています!' + hit-limit: '[entity]の生成は[number]に制限されています!' limits: panel-title: 島の制限 admin: @@ -12,32 +12,33 @@ admin: calc: parameters: <プレイヤー> description: プレイヤーの島の制限を再計算します - finished: '&a 島の再計算が正常に完了しました!' + finished: ' 島の再計算が正常に完了しました!' offset: - unknown: '&c 不明な素材または実体 [name]。' + unknown: ' 不明な素材または実体 [name]。' + description: "材料とエンティティの制限オフセットを管理できます" main: description: 材料とエンティティの制限オフセットを管理できます set: parameters: <プレイヤー> <マテリアル|エンティティ> <番号> description: 材料またはエンティティの制限の新しいオフセットを設定します - success: '&a [name] の制限オフセットが [number] に設定されています。' - same: '&c [name] の制限オフセットはすでに [number] です。' + success: ' [name] の制限オフセットが [number] に設定されています。' + same: ' [name] の制限オフセットはすでに [number] です。' add: parameters: <プレイヤー> <マテリアル|エンティティ> <番号> description: 材料またはエンティティの制限のオフセットを追加します - success: '&a [name] の制限オフセットが [number] まで増加されます。' + success: ' [name] の制限オフセットが [number] まで増加されます。' remove: parameters: <プレイヤー> <マテリアル|エンティティ> <番号> description: 材料またはエンティティの制限のオフセットを削減します - success: '&a [name] の制限オフセットが [number] まで削減されます。' + success: ' [name] の制限オフセットが [number] まで削減されます。' reset: parameters: <プレイヤー> <マテリアル|エンティティ> description: マテリアルまたはエンティティのオフセットを削除します - success: '&a [name] の制限オフセットが 0 に設定されています。' + success: ' [name] の制限オフセットが 0 に設定されています。' view: parameters: <プレイヤー> <マテリアル|エンティティ> description: 材料またはエンティティのオフセットを表示します - message: '&a [name] オフセットが [number] に設定されています。' + message: ' [name] オフセットが [number] に設定されています。' island: limits: description: 島の限界を示す @@ -48,15 +49,18 @@ island: entity-group-name-syntax: '[name]' entity-name-syntax: '[name]' block-name-syntax: '[name]' + env-overworld: "オーバーワールド" + env-nether: "ネザー" + env-end: "ジ・エンド" A2Z: a > z Z2A: z > a errors: no-owner: その島には所有者がいない - not-on-island: '&c この場所には制限が設定されていません。' + not-on-island: ' この場所には制限が設定されていません。' recount: description: あなたの島の限界を語る - now-recounting: '&b 今、話を戻します。これにはしばらく時間がかかるかもしれませんので、お待ちください...' - in-progress: '&c 島の回復作業が進行中です。お待ちください...' - time-out: '&c もう一度話すとタイムアウトになります。島は本当に大きいですか?' - max-color: '&c' - regular-color: '&a' + now-recounting: ' 今、話を戻します。これにはしばらく時間がかかるかもしれませんので、お待ちください...' + in-progress: ' 島の回復作業が進行中です。お待ちください...' + time-out: ' もう一度話すとタイムアウトになります。島は本当に大きいですか?' + max-color: '' + regular-color: '' diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml index ae33d07..0468f31 100644 --- a/src/main/resources/locales/ko.yml +++ b/src/main/resources/locales/ko.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material]는 [number]개로 제한됩니다!" + hit-limit: "[material]는 [number]개로 제한됩니다!" entity-limits: - hit-limit: "&c[entity]는 [number]개로 제한됩니다!" + hit-limit: "[entity]는 [number]개로 제한됩니다!" limits: panel-title: 섬의 경계 admin: @@ -13,51 +13,55 @@ admin: calc: parameters: "<플레이어>" description: 플레이어의 섬 한계를 다시 계산합니다 - finished: "&a 섬 재계산이 성공적으로 완료되었습니다!" + finished: " 섬 재계산이 성공적으로 완료되었습니다!" offset: - unknown: "&c 알 수 없는 자료 또는 엔터티 [name]." + unknown: " 알 수 없는 자료 또는 엔터티 [name]." + description: "재료 및 엔티티에 대한 제한 오프셋을 관리할 수 있습니다." main: description: 재료 및 엔티티에 대한 제한 오프셋을 관리할 수 있습니다. set: parameters: "<플레이어> <소재|엔티티> <숫자>" description: 재료 또는 엔티티 제한에 대한 새로운 오프셋을 설정합니다. - success: "&a [name]에 대한 제한 오프셋이 [number]로 설정되었습니다." - same: "&c [name]에 대한 제한 오프셋은 이미 [number]입니다." + success: " [name]에 대한 제한 오프셋이 [number]로 설정되었습니다." + same: " [name]에 대한 제한 오프셋은 이미 [number]입니다." add: parameters: "<플레이어> <소재|엔티티> <숫자>" description: 재료 또는 엔티티 제한에 대한 오프셋을 추가합니다. - success: "&a [name]에 대한 오프셋 제한이 [number]까지 증가합니다." + success: " [name]에 대한 오프셋 제한이 [number]까지 증가합니다." remove: parameters: "<플레이어> <소재|엔티티> <숫자>" description: 재료 또는 엔티티 제한에 대한 오프셋을 줄입니다. - success: "&a [name]에 대한 오프셋 제한은 [number]까지 감소합니다." + success: " [name]에 대한 오프셋 제한은 [number]까지 감소합니다." reset: parameters: "<플레이어> <소재|엔티티>" description: 재료 또는 엔티티에 대한 오프셋을 제거합니다. - success: "&a [name]에 대한 제한 오프셋이 0으로 설정되었습니다." + success: " [name]에 대한 제한 오프셋이 0으로 설정되었습니다." view: parameters: "<플레이어> <소재|엔티티>" description: 재료 또는 엔티티에 대한 오프셋을 표시합니다. - message: "&a [name] 오프셋이 [number]로 설정되었습니다." + message: " [name] 오프셋이 [number]로 설정되었습니다." island: limits: description: 섬의 경계를 보여주세요 - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c 이 세상에는 제한이 없습니다" + no-limits: " 이 세상에는 제한이 없습니다" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "오버월드" + env-nether: "네더" + env-end: "디 엔드" A2Z: 가 > 지 Z2A: 지 > 아 errors: - no-owner: "&c 그 섬에는 주인이 없다" - not-on-island: "&c 이 위치에는 제한이 설정되어 있지 않습니다." + no-owner: " 그 섬에는 주인이 없다" + not-on-island: " 이 위치에는 제한이 설정되어 있지 않습니다." recount: description: 당신의 섬에 대한 한계를 다시 계산합니다 - now-recounting: "&b 지금 다시 계산 중입니다. 시간이 좀 걸릴 수 있으니 기다려 주세요..." - in-progress: "&c 섬 복구가 진행 중입니다. 잠시만 기다려 주세요..." - time-out: "&c 계산할 때 시간 초과. 섬이 정말 큰가요?" + now-recounting: " 지금 다시 계산 중입니다. 시간이 좀 걸릴 수 있으니 기다려 주세요..." + in-progress: " 섬 복구가 진행 중입니다. 잠시만 기다려 주세요..." + time-out: " 계산할 때 시간 초과. 섬이 정말 큰가요?" diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index 2881f12..2a9a010 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] ierobežots līdz [number]!" + hit-limit: "[material] ierobežots līdz [number]!" entity-limits: - hit-limit: "&c[entity] rašanās ierobežots līdz [number]!" + hit-limit: "[entity] rašanās ierobežots līdz [number]!" limits: panel-title: Salas ierobežojumi admin: @@ -13,51 +13,55 @@ admin: calc: parameters: "" description: pārrēķinā salas ierobežojumus priekš spēlētāja - finished: "&aSalas ierobežojumu pārrēķināšana pabeigta!" + finished: "Salas ierobežojumu pārrēķināšana pabeigta!" offset: unknown: un c Nezināms materiāls vai vienība [name]. + description: "ļauj pārvaldīt limitu kompensācijas materiāliem un entītijām" main: description: ļauj pārvaldīt limitu kompensācijas materiāliem un entītijām set: parameters: " " description: nosaka jaunu nobīdi materiāla vai entītijas ierobežojumam - success: "&a Ierobežojuma nobīde [name] ir iestatīta uz [number]." - same: "&c [name] limita nobīde jau ir [number]." + success: " Ierobežojuma nobīde [name] ir iestatīta uz [number]." + same: " [name] limita nobīde jau ir [number]." add: parameters: " " description: pievieno nobīdi materiāla vai entītijas ierobežojumam - success: "&a Ierobežojuma nobīde [name] tiek palielināta līdz [number]." + success: " Ierobežojuma nobīde [name] tiek palielināta līdz [number]." remove: parameters: " " description: samazina materiāla vai entītijas limita kompensāciju - success: "&a Ierobežojuma nobīde [name] tiek samazināta līdz [number]." + success: " Ierobežojuma nobīde [name] tiek samazināta līdz [number]." reset: parameters: " " description: noņem materiāla vai entītijas nobīdi - success: "&a Ierobežojuma nobīde [name] ir iestatīta uz 0." + success: " Ierobežojuma nobīde [name] ir iestatīta uz 0." view: parameters: " " description: parāda nobīdi materiālam vai vienībai - message: "&a [name] nobīde ir iestatīta uz [number]." + message: " [name] nobīde ir iestatīta uz [number]." island: limits: description: rāda tavas salas ierobežojumus - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c Šajā pasaulē nav noteikti ierobežojumi" + no-limits: " Šajā pasaulē nav noteikti ierobežojumi" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Virszeme" + env-nether: "Nether" + env-end: "Gals" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Tai salai nav saimnieka" - not-on-island: "&c Šai vietai nav noteikti ierobežojumi." + no-owner: " Tai salai nav saimnieka" + not-on-island: " Šai vietai nav noteikti ierobežojumi." recount: description: pārrēķina ierobežojumus tavai salai - now-recounting: "&b Tagad atstāsta. Tas var aizņemt kādu laiku, lūdzu, uzgaidiet..." - in-progress: "&c Notiek salas atjaunošana. Lūdzu, uzgaidiet..." - time-out: "&c Noildze, pārskaitot. Vai tiešām sala ir liela?" + now-recounting: " Tagad atstāsta. Tas var aizņemt kādu laiku, lūdzu, uzgaidiet..." + in-progress: " Notiek salas atjaunošana. Lūdzu, uzgaidiet..." + time-out: " Noildze, pārskaitot. Vai tiešām sala ir liela?" diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml index bdde128..789695a 100644 --- a/src/main/resources/locales/nl.yml +++ b/src/main/resources/locales/nl.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] beperkt tot [number]!" + hit-limit: "[material] beperkt tot [number]!" entity-limits: - hit-limit: "&c[entity] spawning beperkt tot [number]!" + hit-limit: "[entity] spawning beperkt tot [number]!" limits: panel-title: Eilandgrenzen admin: @@ -13,53 +13,57 @@ admin: calc: parameters: "" description: herbereken de eilandlimieten voor de speler - finished: "&a Eiland herberekening succesvol afgerond!" + finished: " Eiland herberekening succesvol afgerond!" offset: - unknown: "&c Onbekend materiaal of entiteit [name]." + unknown: " Onbekend materiaal of entiteit [name]." + description: "maakt het mogelijk om limietoffsets voor materialen en entiteiten te beheren" main: description: maakt het mogelijk om limietoffsets voor materialen en entiteiten te beheren set: parameters: " " description: stelt nieuwe offset in voor materiaal- of entiteitslimiet - success: "&a Limietoffset voor [name] is ingesteld op [number]." - same: "&c Limietoffset voor [name] is al [number]." + success: " Limietoffset voor [name] is ingesteld op [number]." + same: " Limietoffset voor [name] is al [number]." add: parameters: " " description: voegt offset toe voor materiaal- of entiteitslimiet - success: "&a Limietoffset voor [name] is verhoogd tot [number]." + success: " Limietoffset voor [name] is verhoogd tot [number]." remove: parameters: " " description: vermindert offset voor materiaal- of entiteitslimiet - success: "&a Limietoffset voor [name] is verlaagd tot [number]." + success: " Limietoffset voor [name] is verlaagd tot [number]." reset: parameters: " " description: verwijdert offset voor materiaal of entiteit - success: "&a Limietoffset voor [name] is ingesteld op 0." + success: " Limietoffset voor [name] is ingesteld op 0." view: parameters: " " description: geeft offset weer voor materiaal of entiteit - message: "&a [name] offset is ingesteld op [number]." + message: " [name] offset is ingesteld op [number]." island: limits: description: toon uw eilandgrenzen - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c Er zijn geen grenzen gesteld in deze wereld" + no-limits: " Er zijn geen grenzen gesteld in deze wereld" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Bovenwereld" + env-nether: "Nether" + env-end: "Het Einde" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Dat eiland heeft geen eigenaar" - not-on-island: "&c Voor deze locatie zijn geen limieten ingesteld." + no-owner: " Dat eiland heeft geen eigenaar" + not-on-island: " Voor deze locatie zijn geen limieten ingesteld." recount: description: vertelt over de grenzen van uw eiland - now-recounting: "&b Nu aan het navertellen. Dit kan even duren, even geduld + now-recounting: " Nu aan het navertellen. Dit kan even duren, even geduld aub..." - in-progress: "&c Eilandrecovery is bezig. Even geduld..." - time-out: "&c Time-out bij het navertellen. Is het eiland echt groot?" + in-progress: " Eilandrecovery is bezig. Even geduld..." + time-out: " Time-out bij het navertellen. Is het eiland echt groot?" diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index aa338d0..1f9f689 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] limitowany do [number]!" + hit-limit: "[material] limitowany do [number]!" entity-limits: - hit-limit: "&cSpawnowanie [entity] limitowane do [number]!" + hit-limit: "Spawnowanie [entity] limitowane do [number]!" limits: panel-title: Limity wysp admin: @@ -13,52 +13,56 @@ admin: calc: parameters: "" description: ponownie oblicza limity wyspy dla gracza - finished: "&aPrzeliczanie zakończone!" + finished: "Przeliczanie zakończone!" offset: - unknown: "&c Nieznany materiał lub podmiot [name]." + unknown: " Nieznany materiał lub podmiot [name]." + description: "pozwala zarządzać limitów dla materiałów i podmiotów," main: description: pozwala zarządzać limitów dla materiałów i podmiotów, set: parameters: " " description: ustawia nową wartość dla limitu materiału lub encji - success: "&a Przesunięcie limitu dla [name] jest ustawione na [number]." - same: "&c Limit dla [name] jest aktualnie [number]." + success: " Przesunięcie limitu dla [name] jest ustawione na [number]." + same: " Limit dla [name] jest aktualnie [number]." add: parameters: " " description: dodaje przesunięcie dla limitu materiału lub potworów - success: "&a Przesunięcie limitu dla [name] jest zwiększone do [number]." + success: " Przesunięcie limitu dla [name] jest zwiększone do [number]." remove: parameters: " " description: zmniejsza offset dla limitu materiału lub podmiotu - success: "&a Limit dla bloku [name] \nzostał zmniejszony do [number]." + success: " Limit dla bloku [name] \nzostał zmniejszony do [number]." reset: parameters: " " description: usuwa wartość dla materiału lub istoty - success: "&a Limit dla [name] jest ustawione na 0." + success: " Limit dla [name] jest ustawione na 0." view: parameters: " " description: displays offset for material or entity - message: "&a [name] wartość jest ustawiona na [number]." + message: " [name] wartość jest ustawiona na [number]." island: limits: description: pokazuje limity twojej wyspy - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c Na tym świecie nie ma żadnych ograniczeń" + no-limits: " Na tym świecie nie ma żadnych ograniczeń" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Świat Powierzchniowy" + env-nether: "Nether" + env-end: "Kraj" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Ta wyspa nie ma właściciela" - not-on-island: "&c Ta lokalizacja nie ma ustalonych ograniczeń." + no-owner: " Ta wyspa nie ma właściciela" + not-on-island: " Ta lokalizacja nie ma ustalonych ograniczeń." recount: description: określa limity dla twojej wyspy - now-recounting: "&b Teraz przeliczam. To może chwilę potrwać, proszę czekać..." - in-progress: "&c Trwa odzyskiwanie wyspy. Proszę czekać..." - time-out: "&c Przekroczono limit czasu podczas przeliczania. Czy wyspa jest + now-recounting: " Teraz przeliczam. To może chwilę potrwać, proszę czekać..." + in-progress: " Trwa odzyskiwanie wyspy. Proszę czekać..." + time-out: " Przekroczono limit czasu podczas przeliczania. Czy wyspa jest naprawdę duża?" diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index 3db51a5..67cb8d1 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] limitado a [number]!" + hit-limit: "[material] limitado a [number]!" entity-limits: - hit-limit: "&c[entity] gerando limitado a [number]!" + hit-limit: "[entity] gerando limitado a [number]!" limits: panel-title: Limites da ilha admin: @@ -13,51 +13,55 @@ admin: calc: parameters: "" description: recalcular os limites da ilha para o jogador - finished: "&a O recálculo da ilha foi concluído com sucesso!" + finished: " O recálculo da ilha foi concluído com sucesso!" offset: - unknown: "&c Material ou entidade desconhecida [name]." + unknown: " Material ou entidade desconhecida [name]." + description: "permite gerenciar limites de deslocamento para materiais e entidades" main: description: permite gerenciar limites de deslocamento para materiais e entidades set: parameters: " " description: define novo deslocamento para limite de material ou entidade - success: "&a O deslocamento de limite para [name] é definido como [number]." - same: "&c O deslocamento limite para [name] já é [number]." + success: " O deslocamento de limite para [name] é definido como [number]." + same: " O deslocamento limite para [name] já é [number]." add: parameters: " " description: adiciona deslocamento para limite de material ou entidade - success: "&a O deslocamento de limite para [name] é aumentado até [number]." + success: " O deslocamento de limite para [name] é aumentado até [number]." remove: parameters: " " description: reduz o deslocamento para o limite de material ou entidade - success: "&a O deslocamento de limite para [name] é reduzido até [number]." + success: " O deslocamento de limite para [name] é reduzido até [number]." reset: parameters: " " description: remove deslocamento para material ou entidade - success: "&a O deslocamento de limite para [name] é definido como 0." + success: " O deslocamento de limite para [name] é definido como 0." view: parameters: " " description: exibe deslocamento para material ou entidade - message: "&a [name] deslocamento é definido como [number]." + message: " [name] deslocamento é definido como [number]." island: limits: description: mostre os limites da sua ilha - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c Não há limites neste mundo" + no-limits: " Não há limites neste mundo" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Superfície" + env-nether: "Nether" + env-end: "O End" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Aquela ilha não tem dono" - not-on-island: "&c Este local não possui limites definidos." + no-owner: " Aquela ilha não tem dono" + not-on-island: " Este local não possui limites definidos." recount: description: reconta limites para sua ilha - now-recounting: "&b Agora recontando. Isso pode demorar um pouco, aguarde..." - in-progress: "&c A recontagem da ilha está em andamento. Aguarde..." - time-out: "&c Tempo limite ao recontar. A ilha é realmente grande?" + now-recounting: " Agora recontando. Isso pode demorar um pouco, aguarde..." + in-progress: " A recontagem da ilha está em andamento. Aguarde..." + time-out: " Tempo limite ao recontar. A ilha é realmente grande?" diff --git a/src/main/resources/locales/ro.yml b/src/main/resources/locales/ro.yml index 76b0780..8a21765 100644 --- a/src/main/resources/locales/ro.yml +++ b/src/main/resources/locales/ro.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] limitat la [number]!" + hit-limit: "[material] limitat la [number]!" entity-limits: - hit-limit: "&c[entity] reproducere limitata la [number]!" + hit-limit: "[entity] reproducere limitata la [number]!" limits: panel-title: Limitele insulei admin: @@ -13,53 +13,57 @@ admin: calc: parameters: "" description: recalculeaza limitele insulei pentru jocator - finished: "&aRecalcularea insulei terminata cu succes!" + finished: "Recalcularea insulei terminata cu succes!" offset: - unknown: "&c Material sau entitate necunoscută [name]." + unknown: " Material sau entitate necunoscută [name]." + description: "permite gestionarea decalajelor de limite pentru materiale și entități" main: description: permite gestionarea decalajelor de limite pentru materiale și entități set: parameters: " " description: stabilește o nouă compensare pentru limita de material sau entitate - success: "&a Decalajul limită pentru [name] este setat la [number]." - same: "&c Decalajul limită pentru [name] este deja [number]." + success: " Decalajul limită pentru [name] este setat la [number]." + same: " Decalajul limită pentru [name] este deja [number]." add: parameters: " " description: adaugă compensare pentru limita de material sau entitate - success: "&a Compensarea limită pentru [name] este mărită până la [number]." + success: " Compensarea limită pentru [name] este mărită până la [number]." remove: parameters: " " description: reduce compensarea pentru limita de material sau entitate - success: "&a Limita decalajului pentru [name] este redusă până la [number]." + success: " Limita decalajului pentru [name] este redusă până la [number]." reset: parameters: " " description: elimină decalajul pentru material sau entitate - success: "&a Decalajul limită pentru [name] este setat la 0." + success: " Decalajul limită pentru [name] este setat la 0." view: parameters: " " description: afișează offset pentru material sau entitate - message: "&a [name] offset este setat la [number]." + message: " [name] offset este setat la [number]." island: limits: description: iti arata limitele insulei - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c Nu există limite stabilite în această lume" + no-limits: " Nu există limite stabilite în această lume" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Lumea de Suprafață" + env-nether: "Nether" + env-end: "End" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Insula aceea nu are proprietar" - not-on-island: "&c Această locație nu are limite setate." + no-owner: " Insula aceea nu are proprietar" + not-on-island: " Această locație nu are limite setate." recount: description: renumara limitele insulei tale - now-recounting: "&b Acum povestind. Acest lucru ar putea dura ceva timp, vă + now-recounting: " Acum povestind. Acest lucru ar putea dura ceva timp, vă rugăm să așteptați..." - in-progress: "&c Recuperarea insulei este în curs. Va rugam asteptati..." - time-out: "&c Timpul expirat când relatați. Este insula cu adevărat mare?" + in-progress: " Recuperarea insulei este în curs. Va rugam asteptati..." + time-out: " Timpul expirat când relatați. Este insula cu adevărat mare?" diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml index e02eed1..92a7419 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -1,10 +1,10 @@ --- block-limits: - hit-limit: "&e[material] &4eşyasından &5[number] &4koyabilirsin!" + hit-limit: "[material] eşyasından [number] koyabilirsin!" entity-limits: - hit-limit: "&e[entity] &4varlığından &5[number] &4tane koyabilirsin!" + hit-limit: "[entity] varlığından [number] tane koyabilirsin!" limits: - panel-title: "&eAda limiti" + panel-title: "Ada limiti" admin: limits: main: @@ -13,53 +13,57 @@ admin: calc: parameters: "" description: Oyuncu için ada limitlerini tekrar hesapla. - finished: "&aAda hesaplaması başarıyla yapıldı." + finished: "Ada hesaplaması başarıyla yapıldı." offset: - unknown: "&c Bilinmeyen malzeme veya varlık [name]." + unknown: " Bilinmeyen malzeme veya varlık [name]." + description: "malzemeler ve varlıklar için limit ofsetlerini yönetmeye olanak tanır" main: description: malzemeler ve varlıklar için limit ofsetlerini yönetmeye olanak tanır set: parameters: " " description: malzeme veya varlık sınırı için yeni ofset ayarlar - success: "&[name] için Limit ofseti [number] olarak ayarlandı." - same: "&c [name] için sınır ofseti zaten [number]'dır." + success: "[name] için Limit ofseti [number] olarak ayarlandı." + same: " [name] için sınır ofseti zaten [number]'dır." add: parameters: " " description: malzeme veya varlık sınırı için ofset ekler - success: "&[name] için Limit ofseti [number]'ya kadar artırıldı." + success: "[name] için Limit ofseti [number]'ya kadar artırıldı." remove: parameters: " " description: malzeme veya varlık sınırı için ofseti azaltır - success: "&[name] için limit ofseti [number]'ya kadar azaltıldı." + success: "[name] için limit ofseti [number]'ya kadar azaltıldı." reset: parameters: " " description: malzeme veya varlık için ofseti kaldırır - success: "&[name] için Limit ofseti 0 olarak ayarlandı." + success: "[name] için Limit ofseti 0 olarak ayarlandı." view: parameters: " " description: malzeme veya varlık için ofseti görüntüler - message: "&a [name] ofseti [number] olarak ayarlandı." + message: " [name] ofseti [number] olarak ayarlandı." island: limits: description: Ada limitlerini gör. - max-color: "&c" - regular-color: "&9" - block-limit-syntax: "&5[number]&7/&e[limit]" - no-limits: "&c Bu dünyada hiçbir sınır belirlenmedi" + max-color: "" + regular-color: "" + block-limit-syntax: "[number]/[limit]" + no-limits: " Bu dünyada hiçbir sınır belirlenmedi" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Ana Dünya" + env-nether: "Nether" + env-end: "End" A2Z: a > z Z2A: z > a errors: - no-owner: "&c O adanın bir sahibi yok" - not-on-island: "&c Bu lokasyonun belirlenmiş bir sınırı yok." + no-owner: " O adanın bir sahibi yok" + not-on-island: " Bu lokasyonun belirlenmiş bir sınırı yok." recount: description: Ada limitlerini tekrardan hesaplar. - now-recounting: "&b Şimdi anlatmaya başlıyorum. Biraz zaman alabilir, lütfen + now-recounting: " Şimdi anlatmaya başlıyorum. Biraz zaman alabilir, lütfen bekleyin..." - in-progress: "&c Ada geri kazanımı devam ediyor. Lütfen bekleyin..." - time-out: "&c Anlatırken zaman aşımı. Ada gerçekten büyük mü?" + in-progress: " Ada geri kazanımı devam ediyor. Lütfen bekleyin..." + time-out: " Anlatırken zaman aşımı. Ada gerçekten büyük mü?" diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index abffe7e..98bf500 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] обмежено [number]!" + hit-limit: "[material] обмежено [number]!" entity-limits: - hit-limit: "&c[entity] породження обмежено до [number]!" + hit-limit: "[entity] породження обмежено до [number]!" limits: panel-title: Межі острова admin: @@ -15,49 +15,53 @@ admin: description: перерахувати обмеження острова для гравця finished: Перерахунок острова успішно завершено! offset: - unknown: "&c Невідомий матеріал або сутність [name]." + unknown: " Невідомий матеріал або сутність [name]." + description: "дозволяє керувати зсувами лімітів для матеріалів і сутностей" main: description: дозволяє керувати зсувами лімітів для матеріалів і сутностей set: parameters: "<гравець> <матеріал|сутність> <номер>" description: встановлює нове зміщення для обмеження матеріалу або сутності - success: "&a Лімітне зміщення для [name] встановлено на [number]." - same: "&c Обмежене зміщення для [name] вже [number]." + success: " Лімітне зміщення для [name] встановлено на [number]." + same: " Обмежене зміщення для [name] вже [number]." add: parameters: "<гравець> <матеріал|сутність> <номер>" description: додає зсув для обмеження матеріалу або сутності - success: "&a Лімітне зміщення для [name] збільшується до [number]." + success: " Лімітне зміщення для [name] збільшується до [number]." remove: parameters: "<гравець> <матеріал|сутність> <номер>" description: зменшує зсув для ліміту матеріалу або сутності - success: "&a Лімітне зміщення для [name] зменшується до [number]." + success: " Лімітне зміщення для [name] зменшується до [number]." reset: parameters: "<гравець> <матеріал|сутність>" description: видаляє зсув для матеріалу або сутності - success: "&a Лімітне зміщення для [name] встановлено на 0." + success: " Лімітне зміщення для [name] встановлено на 0." view: parameters: "<гравець> <матеріал|сутність>" description: відображає зсув для матеріалу або сутності - message: "&зміщення [name] встановлено на [number]." + message: "зміщення [name] встановлено на [number]." island: limits: description: покажіть межі свого острова - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c У цьому світі немає обмежень" + no-limits: " У цьому світі немає обмежень" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Звичайний світ" + env-nether: "Незер" + env-end: "Край" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Цей острів не має власника" - not-on-island: "&c Це місце не має обмежень." + no-owner: " Цей острів не має власника" + not-on-island: " Це місце не має обмежень." recount: description: перераховує ліміти для вашого острова - now-recounting: "&b Тепер перераховую. Це може зайняти деякий час, зачекайте..." - in-progress: "&c Перерахування острова триває. Будь ласка, зачекайте..." - time-out: "&c Тайм-аут під час перерахунку. Чи справді острів великий?" + now-recounting: " Тепер перераховую. Це може зайняти деякий час, зачекайте..." + in-progress: " Перерахування острова триває. Будь ласка, зачекайте..." + time-out: " Тайм-аут під час перерахунку. Чи справді острів великий?" diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml index 4a199ef..cbac863 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] bị giới hạn trong [number] khối!" + hit-limit: "[material] bị giới hạn trong [number] khối!" entity-limits: - hit-limit: "&c[entity] bị giới hạn trong [number] thực thể!" + hit-limit: "[entity] bị giới hạn trong [number] thực thể!" limits: panel-title: Giới hạn đảo admin: @@ -13,51 +13,55 @@ admin: calc: parameters: "" description: tính toán lại giới hạn đảo của người chơi - finished: "&aTính toán đã hoàn thành!" + finished: "Tính toán đã hoàn thành!" offset: - unknown: "&c Tài liệu hoặc thực thể không xác định [name]." + unknown: " Tài liệu hoặc thực thể không xác định [name]." + description: "cho phép quản lý các giới hạn bù trừ cho vật liệu và thực thể" main: description: cho phép quản lý các giới hạn bù trừ cho vật liệu và thực thể set: parameters: " " description: thiết lập độ lệch mới cho giới hạn vật liệu hoặc thực thể - success: "&a Độ lệch giới hạn cho [name] được đặt thành [number]." - same: "&c Độ lệch giới hạn cho [name] đã là [number]." + success: " Độ lệch giới hạn cho [name] được đặt thành [number]." + same: " Độ lệch giới hạn cho [name] đã là [number]." add: parameters: " " description: thêm độ lệch cho giới hạn vật liệu hoặc thực thể - success: "&a Độ lệch giới hạn cho [name] được tăng lên cho đến [number]." + success: " Độ lệch giới hạn cho [name] được tăng lên cho đến [number]." remove: parameters: " " description: giảm độ lệch cho giới hạn vật liệu hoặc thực thể - success: "&a Độ lệch giới hạn cho [name] được giảm xuống đến [number]." + success: " Độ lệch giới hạn cho [name] được giảm xuống đến [number]." reset: parameters: " " description: xóa phần bù cho vật liệu hoặc thực thể - success: "&a Độ lệch giới hạn cho [name] được đặt thành 0." + success: " Độ lệch giới hạn cho [name] được đặt thành 0." view: parameters: " " description: hiển thị bù trừ cho vật liệu hoặc thực thể - message: "&a [name] bù trừ được đặt thành [number]." + message: " [name] bù trừ được đặt thành [number]." island: limits: description: xem giới hạn đảo của bạn - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c Không có giới hạn nào được đặt ra trên thế giới này" + no-limits: " Không có giới hạn nào được đặt ra trên thế giới này" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "Thế Giới Thường" + env-nether: "Nether" + env-end: "End" A2Z: a > z Z2A: z > a errors: - no-owner: "&c Hòn đảo đó không có chủ sở hữu" - not-on-island: "&c Vị trí này không có giới hạn nào được đặt ra." + no-owner: " Hòn đảo đó không có chủ sở hữu" + not-on-island: " Vị trí này không có giới hạn nào được đặt ra." recount: description: tính toán lại giới hạn đảo của bạn - now-recounting: "&b Đang đếm lại. Việc này có thể mất một lúc, vui lòng đợi..." - in-progress: "&c Đảo đang được thu hồi. Vui lòng đợi..." - time-out: "&c Hết giờ khi kể lại. Hòn đảo có thực sự lớn không?" + now-recounting: " Đang đếm lại. Việc này có thể mất một lúc, vui lòng đợi..." + in-progress: " Đảo đang được thu hồi. Vui lòng đợi..." + time-out: " Hết giờ khi kể lại. Hòn đảo có thực sự lớn không?" diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index 2a816bb..a7b2829 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material] 已限制到 [number]!" + hit-limit: "[material] 已限制到 [number]!" entity-limits: - hit-limit: "&c[entity] 生成已限制到 [number]!" + hit-limit: "[entity] 生成已限制到 [number]!" limits: panel-title: 岛屿限制 admin: @@ -13,51 +13,55 @@ admin: calc: parameters: "<玩家>" description: 重新计算玩家的岛屿限制 - finished: "&a岛屿重计算已完成!" + finished: "岛屿重计算已完成!" offset: - unknown: "&c 未知物质或实体 [name]。" + unknown: " 未知物质或实体 [name]。" + description: "允许管理材料和实体的限制偏移" main: description: 允许管理材料和实体的限制偏移 set: parameters: "<玩家> <材质|实体> <数字>" description: 为材料或实体限制设置新的偏移 - success: "&a 将 [name] 的限制偏移量设置为 [number]。" - same: "&c [name] 的限制偏移量已经为 [number]。" + success: " 将 [name] 的限制偏移量设置为 [number]。" + same: " [name] 的限制偏移量已经为 [number]。" add: parameters: "<玩家> <材质|实体> <数字>" description: 添加材料或实体限制的偏移 - success: "&a [name] 的限制偏移量增加至 [number]。" + success: " [name] 的限制偏移量增加至 [number]。" remove: parameters: "<玩家> <材质|实体> <数字>" description: 减少材料或实体限制的偏移量 - success: "&a [name] 的限制偏移量减少至 [number]。" + success: " [name] 的限制偏移量减少至 [number]。" reset: parameters: "<玩家> <材质|实体>" description: 删除材料或实体的偏移 - success: "&a [name] 的限制偏移量设置为 0。" + success: " [name] 的限制偏移量设置为 0。" view: parameters: "<玩家> <材质|实体>" description: 显示材料或实体的偏移 - message: "&a [name] offset 设置为 [number]。" + message: " [name] offset 设置为 [number]。" island: limits: description: 显示您的岛屿限制 - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c 这世上没有限制" + no-limits: " 这世上没有限制" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "主世界" + env-nether: "下界" + env-end: "末地" A2Z: a > z Z2A: z > a errors: - no-owner: "&c 那个岛没有主人" - not-on-island: "&c 此位置未设置限制。" + no-owner: " 那个岛没有主人" + not-on-island: " 此位置未设置限制。" recount: description: 重新计数岛屿限制 - now-recounting: "&b 开始重新计算. 可能需要一定的时间, 请稍等......" - in-progress: "&c 重新计算岛屿限制中. 请稍等......" - time-out: "&c 重新计算超时. 岛屿太大了吗?" + now-recounting: " 开始重新计算. 可能需要一定的时间, 请稍等......" + in-progress: " 重新计算岛屿限制中. 请稍等......" + time-out: " 重新计算超时. 岛屿太大了吗?" diff --git a/src/main/resources/locales/zh-TW.yml b/src/main/resources/locales/zh-TW.yml index cad8dca..5537b1c 100644 --- a/src/main/resources/locales/zh-TW.yml +++ b/src/main/resources/locales/zh-TW.yml @@ -1,8 +1,8 @@ --- block-limits: - hit-limit: "&c[material]僅限[number]!" + hit-limit: "[material]僅限[number]!" entity-limits: - hit-limit: "&c[entity] 產生僅限於 [number]!" + hit-limit: "[entity] 產生僅限於 [number]!" limits: panel-title: 島嶼限制 admin: @@ -13,51 +13,55 @@ admin: calc: parameters: "<玩家>" description: 重新計算玩家的島嶼限制 - finished: "&a 島嶼重新計算成功完成!" + finished: " 島嶼重新計算成功完成!" offset: - unknown: "&c 未知材料或實體[name]。" + unknown: " 未知材料或實體[name]。" + description: "允許管理材料和實體的限制偏移" main: description: 允許管理材料和實體的限制偏移 set: parameters: "<玩家> <材質|實體> <數字>" description: 設定材料或實體限制的新偏移 - success: "&a [name] 的限制偏移量設定為 [number]。" - same: "&c [name] 的限制偏移量已經是 [number]。" + success: " [name] 的限制偏移量設定為 [number]。" + same: " [name] 的限制偏移量已經是 [number]。" add: parameters: "<玩家> <材質|實體> <數字>" description: 添加材料或實體限制的偏移量 - success: "&a [name] 的限制偏移量增加到 [number]。" + success: " [name] 的限制偏移量增加到 [number]。" remove: parameters: "<玩家> <材質|實體> <數字>" description: 減少材料或實體限制的偏移 - success: "&a [name] 的限制偏移量減少到 [number]。" + success: " [name] 的限制偏移量減少到 [number]。" reset: parameters: "<玩家> <材質|實體>" description: 刪除材質或實體的偏移 - success: "&a [名稱] 的限制偏移量設定為 0。" + success: " [名稱] 的限制偏移量設定為 0。" view: parameters: "<玩家> <材質|實體>" description: 顯示材料或實體的偏移 - message: "&a [name] 偏移量設定為 [number]。" + message: " [name] 偏移量設定為 [number]。" island: limits: description: 顯示您的島嶼限制 - max-color: "&c" - regular-color: "&a" + max-color: "" + regular-color: "" block-limit-syntax: "[number]/[limit]" - no-limits: "&c 這個世界沒有限制" + no-limits: " 這個世界沒有限制" panel: title-syntax: "[title] [sort]" entity-group-name-syntax: "[name]" entity-name-syntax: "[name]" block-name-syntax: "[name]" + env-overworld: "主世界" + env-nether: "下界" + env-end: "終界" A2Z: a > z Z2A: z > a errors: - no-owner: "&c 那島沒有主人" - not-on-island: "&c 該位置沒有設定限制。" + no-owner: " 那島沒有主人" + not-on-island: " 該位置沒有設定限制。" recount: description: 詳細說明您的島嶼的限制 - now-recounting: "&b 現在重述。這可能需要一段時間,請稍候..." - in-progress: "&c 島回收正在進行中。請稍等..." - time-out: "&c 重新計數時逾時。島真的很大嗎?" + now-recounting: " 現在重述。這可能需要一段時間,請稍候..." + in-progress: " 島回收正在進行中。請稍等..." + time-out: " 重新計數時逾時。島真的很大嗎?" diff --git a/src/test/java/world/bentobox/limits/EntityGroupTest.java b/src/test/java/world/bentobox/limits/EntityGroupTest.java index d93e81d..d44b5ee 100644 --- a/src/test/java/world/bentobox/limits/EntityGroupTest.java +++ b/src/test/java/world/bentobox/limits/EntityGroupTest.java @@ -16,85 +16,85 @@ import org.mockbukkit.mockbukkit.MockBukkit; -public class EntityGroupTest { +class EntityGroupTest { private EntityGroup group; @BeforeEach - public void setUp() { + void setUp() { MockBukkit.mock(); group = new EntityGroup("monsters", Set.of(EntityType.ZOMBIE, EntityType.SKELETON), 10, Material.ZOMBIE_HEAD); } @AfterEach - public void tearDown() { + void tearDown() { MockBukkit.unmock(); } @Test - public void testConstructorAndGetters() { + void testConstructorAndGetters() { assertEquals("monsters", group.getName()); assertEquals(Set.of(EntityType.ZOMBIE, EntityType.SKELETON), group.getTypes()); assertEquals(10, group.getLimit()); } @Test - public void testContainsMember() { + void testContainsMember() { assertTrue(group.contains(EntityType.ZOMBIE)); } @Test - public void testContainsNonMember() { + void testContainsNonMember() { assertFalse(group.contains(EntityType.CREEPER)); } @Test - public void testGetIconWhenSet() { + void testGetIconWhenSet() { assertEquals(Material.ZOMBIE_HEAD, group.getIcon()); } @Test - public void testGetIconWhenNull() { + void testGetIconWhenNull() { EntityGroup noIcon = new EntityGroup("passive", Set.of(EntityType.COW), 5, null); assertEquals(Material.BARRIER, noIcon.getIcon()); } @Test - public void testEqualsSameName() { + void testEqualsSameName() { EntityGroup other = new EntityGroup("monsters", Set.of(EntityType.CREEPER), 20, Material.STONE); assertEquals(group, other); } @Test - public void testEqualsDifferentName() { + void testEqualsDifferentName() { EntityGroup other = new EntityGroup("animals", Set.of(EntityType.ZOMBIE), 10, Material.ZOMBIE_HEAD); assertNotEquals(group, other); } @Test - public void testEqualsNull() { + void testEqualsNull() { assertNotEquals(null, group); } @Test - public void testEqualsDifferentClass() { + void testEqualsDifferentClass() { assertNotEquals("monsters", group); } @Test - public void testHashCodeConsistent() { + void testHashCodeConsistent() { EntityGroup other = new EntityGroup("monsters", Set.of(EntityType.CREEPER), 20, Material.STONE); assertEquals(group.hashCode(), other.hashCode()); } @Test - public void testHashCodeDifferent() { + void testHashCodeDifferent() { EntityGroup other = new EntityGroup("animals", Set.of(EntityType.ZOMBIE), 10, Material.ZOMBIE_HEAD); assertNotEquals(group.hashCode(), other.hashCode()); } @Test - public void testToStringContainsName() { + void testToStringContainsName() { String str = group.toString(); assertNotNull(str); assertTrue(str.contains("monsters")); diff --git a/src/test/java/world/bentobox/limits/JoinListenerTest.java b/src/test/java/world/bentobox/limits/JoinListenerTest.java index 7c3254e..96e8049 100644 --- a/src/test/java/world/bentobox/limits/JoinListenerTest.java +++ b/src/test/java/world/bentobox/limits/JoinListenerTest.java @@ -1,7 +1,9 @@ package world.bentobox.limits; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -15,12 +17,15 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Stream; import java.util.UUID; +import net.kyori.adventure.text.Component; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.OfflinePlayer; +import org.bukkit.World.Environment; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.event.player.PlayerJoinEvent; @@ -31,6 +36,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -55,7 +63,7 @@ */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class JoinListenerTest { +class JoinListenerTest { @Mock private Limits addon; @@ -84,7 +92,7 @@ public class JoinListenerTest { private MockedStatic mockedBukkit; @BeforeEach - public void setUp() { + void setUp() { MockBukkit.mock(); jl = new JoinListener(addon); // Setup addon @@ -122,12 +130,12 @@ public void setUp() { when(owner.isOnline()).thenReturn(true); when(owner.getPlayer()).thenReturn(player); mockedBukkit.when(() -> Bukkit.getOfflinePlayer(any(UUID.class))).thenReturn(owner); - mockedBukkit.when(() -> Bukkit.getPluginManager()).thenReturn(pim); + mockedBukkit.when(Bukkit::getPluginManager).thenReturn(pim); } @AfterEach - public void tearDown() { + void tearDown() { mockedBukkit.close(); MockBukkit.unmock(); } @@ -137,7 +145,7 @@ public void tearDown() { * {@link world.bentobox.limits.listeners.JoinListener#onNewIsland(world.bentobox.bentobox.api.events.island.IslandEvent)}. */ @Test - public void testOnNewIslandWrongReason() { + void testOnNewIslandWrongReason() { IslandEvent e = new IslandEvent(island, null, false, null, IslandEvent.Reason.BAN); jl.onNewIsland(e); verify(island, never()).getWorld(); @@ -148,7 +156,7 @@ public void testOnNewIslandWrongReason() { * {@link world.bentobox.limits.listeners.JoinListener#onNewIsland(world.bentobox.bentobox.api.events.island.IslandEvent)}. */ @Test - public void testOnNewIslandRegistered() { + void testOnNewIslandRegistered() { IslandEvent e = new IslandEvent(island, null, false, null, IslandEvent.Reason.REGISTERED); jl.onNewIsland(e); verify(island).getWorld(); @@ -159,7 +167,7 @@ public void testOnNewIslandRegistered() { * {@link world.bentobox.limits.listeners.JoinListener#onNewIsland(world.bentobox.bentobox.api.events.island.IslandEvent)}. */ @Test - public void testOnNewIslandResetted() { + void testOnNewIslandResetted() { IslandEvent e = new IslandEvent(island, null, false, null, IslandEvent.Reason.RESETTED); jl.onNewIsland(e); verify(island).getWorld(); @@ -169,7 +177,7 @@ public void testOnNewIslandResetted() { * Test method for {@link world.bentobox.limits.listeners.JoinListener#onNewIsland(world.bentobox.bentobox.api.events.island.IslandEvent)}. */ @Test - public void testOnNewIslandCreated() { + void testOnNewIslandCreated() { when(addon.inGameModeWorld(any())).thenReturn(true); IslandEvent e = new IslandEvent(island, null, false, null, IslandEvent.Reason.CREATED); jl.onNewIsland(e); @@ -181,7 +189,7 @@ public void testOnNewIslandCreated() { * Test method for {@link world.bentobox.limits.listeners.JoinListener#onNewIsland(world.bentobox.bentobox.api.events.island.IslandEvent)}. */ @Test - public void testOnNewIslandCreatedOffline() { + void testOnNewIslandCreatedOffline() { when(owner.isOnline()).thenReturn(false); when(addon.inGameModeWorld(any())).thenReturn(true); IslandEvent e = new IslandEvent(island, null, false, null, IslandEvent.Reason.CREATED); @@ -194,7 +202,7 @@ public void testOnNewIslandCreatedOffline() { * Test method for {@link world.bentobox.limits.listeners.JoinListener#onNewIsland(world.bentobox.bentobox.api.events.island.IslandEvent)}. */ @Test - public void testOnNewIslandCreatedNoNameOrPermPrefix() { + void testOnNewIslandCreatedNoNameOrPermPrefix() { when(addon.getGameModeName(any())).thenReturn(""); when(addon.getGameModePermPrefix(any())).thenReturn("bskyblock."); when(addon.inGameModeWorld(any())).thenReturn(true); @@ -211,7 +219,7 @@ public void testOnNewIslandCreatedNoNameOrPermPrefix() { * {@link world.bentobox.limits.listeners.JoinListener#onOwnerChange(world.bentobox.bentobox.api.events.team.TeamEvent.TeamSetownerEvent)}. */ @Test - public void testOnOwnerChange() { + void testOnOwnerChange() { TeamSetownerEvent e = mock(TeamSetownerEvent.class); when(e.getIsland()).thenReturn(island); when(e.getNewOwner()).thenReturn(UUID.randomUUID()); @@ -225,8 +233,8 @@ public void testOnOwnerChange() { * {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. */ @Test - public void testOnPlayerJoin() { - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); + void testOnPlayerJoin() { + PlayerJoinEvent e = new PlayerJoinEvent(player, Component.text("welcome")); jl.onPlayerJoin(e); verify(addon).getGameModes(); verify(bll).setIsland("unique_id", ibc); @@ -237,9 +245,9 @@ public void testOnPlayerJoin() { * {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. */ @Test - public void testOnPlayerJoinIBCNull() { + void testOnPlayerJoinIBCNull() { ibc = null; - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); + PlayerJoinEvent e = new PlayerJoinEvent(player, Component.text("welcome")); jl.onPlayerJoin(e); verify(addon).getGameModes(); verify(bll, never()).setIsland("unique_id", ibc); @@ -250,88 +258,47 @@ public void testOnPlayerJoinIBCNull() { * {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. */ @Test - public void testOnPlayerJoinWithPermNotLimits() { + void testOnPlayerJoinWithPermNotLimits() { Set perms = new HashSet<>(); PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); when(permAtt.getPermission()).thenReturn("bskyblock.my.perm.for.game"); perms.add(permAtt); when(player.getEffectivePermissions()).thenReturn(perms); - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); + PlayerJoinEvent e = new PlayerJoinEvent(player, Component.text("welcome")); jl.onPlayerJoin(e); verify(addon).getGameModes(); verify(bll).setIsland("unique_id", ibc); } - /** - * Test method for - * {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. - */ - @Test - public void testOnPlayerJoinWithPermLimitsWrongSize() { - Set perms = new HashSet<>(); - PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); - when(permAtt.getPermission()).thenReturn("bskyblock.island.limit.my.perm.for.game"); - when(permAtt.getValue()).thenReturn(true); - perms.add(permAtt); - when(player.getEffectivePermissions()).thenReturn(perms); - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); - jl.onPlayerJoin(e); - verify(addon).logError( - "Player tastybento has permission: 'bskyblock.island.limit.my.perm.for.game' but format must be 'bskyblock.island.limit.MATERIAL.NUMBER', 'bskyblock.island.limit.ENTITY-TYPE.NUMBER', or 'bskyblock.island.limit.ENTITY-GROUP.NUMBER' Ignoring..."); - } - - /** - * Test method for - * {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. - */ - @Test - public void testOnPlayerJoinWithPermLimitsInvalidMaterial() { - Set perms = new HashSet<>(); - PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); - when(permAtt.getPermission()).thenReturn("bskyblock.island.limit.mumbo.34"); - when(permAtt.getValue()).thenReturn(true); - perms.add(permAtt); - when(player.getEffectivePermissions()).thenReturn(perms); - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); - jl.onPlayerJoin(e); - verify(addon).logError( - "Player tastybento has permission: 'bskyblock.island.limit.mumbo.34' but MUMBO is not a valid material or entity type/group. Ignoring..."); - } - - /** - * Test method for - * {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. - */ - @Test - public void testOnPlayerJoinWithPermLimitsWildcard() { - Set perms = new HashSet<>(); - PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); - when(permAtt.getPermission()).thenReturn("bskyblock.island.limit.*"); - when(permAtt.getValue()).thenReturn(true); - perms.add(permAtt); - when(player.getEffectivePermissions()).thenReturn(perms); - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); - jl.onPlayerJoin(e); - verify(addon).logError( - "Player tastybento has permission: 'bskyblock.island.limit.*' but wildcards are not allowed. Ignoring..."); + private static Stream invalidPermissionLimits() { + return Stream.of( + Arguments.of("bskyblock.island.limit.my.perm.for.game", + "Player tastybento has permission: 'bskyblock.island.limit.my.perm.for.game' but format must be 'bskyblock.island.limit.[ENV.]KEY.NUMBER' where ENV is overworld|nether|end and KEY is a material, entity type, or group name. Ignoring..."), + Arguments.of("bskyblock.island.limit.mumbo.34", + "Player tastybento has permission: 'bskyblock.island.limit.mumbo.34' but MUMBO is not a valid material or entity type/group. Ignoring..."), + Arguments.of("bskyblock.island.limit.*", + "Player tastybento has permission: 'bskyblock.island.limit.*' but wildcards are not allowed. Ignoring..."), + Arguments.of("bskyblock.island.limit.STONE.abc", + "Player tastybento has permission: 'bskyblock.island.limit.STONE.abc' but the last part MUST be an integer! Ignoring...")); } /** * Test method for * {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. + * Each invalid permission format should be logged and ignored. */ - @Test - public void testOnPlayerJoinWithPermLimitsNotNumber() { + @ParameterizedTest + @MethodSource("invalidPermissionLimits") + void testOnPlayerJoinWithInvalidPermLimits(String permission, String expectedError) { Set perms = new HashSet<>(); PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); - when(permAtt.getPermission()).thenReturn("bskyblock.island.limit.STONE.abc"); + when(permAtt.getPermission()).thenReturn(permission); when(permAtt.getValue()).thenReturn(true); perms.add(permAtt); when(player.getEffectivePermissions()).thenReturn(perms); - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); + PlayerJoinEvent e = new PlayerJoinEvent(player, Component.text("welcome")); jl.onPlayerJoin(e); - verify(addon).logError( - "Player tastybento has permission: 'bskyblock.island.limit.STONE.abc' but the last part MUST be an integer! Ignoring..."); + verify(addon).logError(expectedError); } /** @@ -339,17 +306,17 @@ public void testOnPlayerJoinWithPermLimitsNotNumber() { * {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. */ @Test - public void testOnPlayerJoinWithPermLimitsSuccess() { + void testOnPlayerJoinWithPermLimitsSuccess() { Set perms = new HashSet<>(); PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); when(permAtt.getPermission()).thenReturn("bskyblock.island.limit.STONE.24"); when(permAtt.getValue()).thenReturn(true); perms.add(permAtt); when(player.getEffectivePermissions()).thenReturn(perms); - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); + PlayerJoinEvent e = new PlayerJoinEvent(player, Component.text("welcome")); jl.onPlayerJoin(e); verify(addon, never()).logError(anyString()); - verify(ibc).setBlockLimit(eq(Material.STONE.getKey()), eq(24)); + verify(ibc).setBlockLimit(Environment.NORMAL, Material.STONE.getKey(), 24); } /** @@ -357,17 +324,17 @@ public void testOnPlayerJoinWithPermLimitsSuccess() { * {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. */ @Test - public void testOnPlayerJoinWithPermLimitsSuccessEntity() { + void testOnPlayerJoinWithPermLimitsSuccessEntity() { Set perms = new HashSet<>(); PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); when(permAtt.getPermission()).thenReturn("bskyblock.island.limit.BAT.24"); when(permAtt.getValue()).thenReturn(true); perms.add(permAtt); when(player.getEffectivePermissions()).thenReturn(perms); - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); + PlayerJoinEvent e = new PlayerJoinEvent(player, Component.text("welcome")); jl.onPlayerJoin(e); verify(addon, never()).logError(anyString()); - verify(ibc).setEntityLimit(eq(EntityType.BAT), eq(24)); + verify(ibc).setEntityLimit(Environment.NORMAL, EntityType.BAT, 24); } /** @@ -375,17 +342,17 @@ public void testOnPlayerJoinWithPermLimitsSuccessEntity() { * {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. */ @Test - public void testOnPlayerJoinWithPermLimitsSuccessEntityGroup() { + void testOnPlayerJoinWithPermLimitsSuccessEntityGroup() { Set perms = new HashSet<>(); PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); when(permAtt.getPermission()).thenReturn("bskyblock.island.limit.friendly.24"); when(permAtt.getValue()).thenReturn(true); perms.add(permAtt); when(player.getEffectivePermissions()).thenReturn(perms); - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); + PlayerJoinEvent e = new PlayerJoinEvent(player, Component.text("welcome")); jl.onPlayerJoin(e); verify(addon, never()).logError(anyString()); - verify(ibc).setEntityGroupLimit(eq("friendly"), eq(24)); + verify(ibc).setEntityGroupLimit(Environment.NORMAL, "friendly", 24); } /** @@ -393,7 +360,7 @@ public void testOnPlayerJoinWithPermLimitsSuccessEntityGroup() { * {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. */ @Test - public void testOnPlayerJoinWithPermLimitsMultiPerms() { + void testOnPlayerJoinWithPermLimitsMultiPerms() { Set perms = new HashSet<>(); PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); when(permAtt.getPermission()).thenReturn("bskyblock.island.limit.STONE.24"); @@ -421,23 +388,23 @@ public void testOnPlayerJoinWithPermLimitsMultiPerms() { perms.add(permAtt6); when(player.getEffectivePermissions()).thenReturn(perms); - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); + PlayerJoinEvent e = new PlayerJoinEvent(player, Component.text("welcome")); jl.onPlayerJoin(e); verify(addon, never()).logError(anyString()); - verify(ibc).setBlockLimit(eq(Material.STONE.getKey()), eq(24)); - verify(ibc).setBlockLimit(eq(Material.SHORT_GRASS.getKey()), eq(14)); - verify(ibc).setBlockLimit(eq(Material.DIRT.getKey()), eq(34)); - verify(ibc).setEntityLimit(eq(EntityType.CHICKEN), eq(34)); - verify(ibc).setEntityLimit(eq(EntityType.CAVE_SPIDER), eq(4)); + verify(ibc).setBlockLimit(Environment.NORMAL, Material.STONE.getKey(), 24); + verify(ibc).setBlockLimit(Environment.NORMAL, Material.SHORT_GRASS.getKey(), 14); + verify(ibc).setBlockLimit(Environment.NORMAL, Material.DIRT.getKey(), 34); + verify(ibc).setEntityLimit(Environment.NORMAL, EntityType.CHICKEN, 34); + verify(ibc).setEntityLimit(Environment.NORMAL, EntityType.CAVE_SPIDER, 4); } /** * Test method for {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. */ @Test - public void testOnPlayerJoinWithPermLimitsMultiPermsSameMaterial() { + void testOnPlayerJoinWithPermLimitsMultiPermsSameMaterial() { // IBC - set the block limit for STONE to be 25 already - when(ibc.getBlockLimit(any())).thenReturn(25); + when(ibc.getBlockLimit(any(Environment.class), any(NamespacedKey.class))).thenReturn(25); Set perms = new HashSet<>(); PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); when(permAtt.getPermission()).thenReturn("bskyblock.island.limit.STONE.24"); @@ -452,22 +419,22 @@ public void testOnPlayerJoinWithPermLimitsMultiPermsSameMaterial() { when(permAtt3.getValue()).thenReturn(true); perms.add(permAtt3); when(player.getEffectivePermissions()).thenReturn(perms); - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); + PlayerJoinEvent e = new PlayerJoinEvent(player, Component.text("welcome")); jl.onPlayerJoin(e); verify(addon, never()).logError(anyString()); // Only the limit over 25 should be set - verify(ibc, never()).setBlockLimit(eq(Material.STONE.getKey()), eq(24)); - verify(ibc, never()).setBlockLimit(eq(Material.STONE.getKey()), eq(14)); - verify(ibc).setBlockLimit(eq(Material.STONE.getKey()), eq(34)); + verify(ibc, never()).setBlockLimit(Environment.NORMAL, Material.STONE.getKey(), 24); + verify(ibc, never()).setBlockLimit(Environment.NORMAL, Material.STONE.getKey(), 14); + verify(ibc).setBlockLimit(Environment.NORMAL, Material.STONE.getKey(), 34); } /** * Test method for {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. */ @Test - public void testOnPlayerJoinWithPermLimitsMultiPermsSameEntity() { + void testOnPlayerJoinWithPermLimitsMultiPermsSameEntity() { // IBC - set the entity limit for BAT to be 25 already - when(ibc.getEntityLimit(any())).thenReturn(25); + when(ibc.getEntityLimit(any(Environment.class), any(EntityType.class))).thenReturn(25); Set perms = new HashSet<>(); PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); when(permAtt.getPermission()).thenReturn("bskyblock.island.limit.BAT.24"); @@ -482,13 +449,13 @@ public void testOnPlayerJoinWithPermLimitsMultiPermsSameEntity() { when(permAtt3.getValue()).thenReturn(true); perms.add(permAtt3); when(player.getEffectivePermissions()).thenReturn(perms); - PlayerJoinEvent e = new PlayerJoinEvent(player, "welcome"); + PlayerJoinEvent e = new PlayerJoinEvent(player, Component.text("welcome")); jl.onPlayerJoin(e); verify(addon, never()).logError(anyString()); // Only the limit over 25 should be set - verify(ibc, never()).setEntityLimit(eq(EntityType.BAT), eq(24)); - verify(ibc, never()).setEntityLimit(eq(EntityType.BAT), eq(14)); - verify(ibc).setEntityLimit(eq(EntityType.BAT), eq(34)); + verify(ibc, never()).setEntityLimit(Environment.NORMAL, EntityType.BAT, 24); + verify(ibc, never()).setEntityLimit(Environment.NORMAL, EntityType.BAT, 14); + verify(ibc).setEntityLimit(Environment.NORMAL, EntityType.BAT, 34); } /** @@ -496,7 +463,7 @@ public void testOnPlayerJoinWithPermLimitsMultiPermsSameEntity() { * {@link world.bentobox.limits.listeners.JoinListener#onUnregisterIsland(world.bentobox.bentobox.api.events.island.IslandEvent)}. */ @Test - public void testOnUnregisterIslandNotUnregistered() { + void testOnUnregisterIslandNotUnregistered() { IslandEvent e = new IslandEvent(island, null, false, null, IslandEvent.Reason.BAN); jl.onUnregisterIsland(e); verify(island, never()).getWorld(); @@ -507,7 +474,7 @@ public void testOnUnregisterIslandNotUnregistered() { * {@link world.bentobox.limits.listeners.JoinListener#onUnregisterIsland(world.bentobox.bentobox.api.events.island.IslandEvent)}. */ @Test - public void testOnUnregisterIslandNotInWorld() { + void testOnUnregisterIslandNotInWorld() { IslandEvent e = new IslandEvent(island, null, false, null, IslandEvent.Reason.UNREGISTERED); jl.onUnregisterIsland(e); verify(island).getWorld(); @@ -519,28 +486,28 @@ public void testOnUnregisterIslandNotInWorld() { * {@link world.bentobox.limits.listeners.JoinListener#onUnregisterIsland(world.bentobox.bentobox.api.events.island.IslandEvent)}. */ @Test - public void testOnUnregisterIslandInWorld() { - @SuppressWarnings("unchecked") - Map map = mock(Map.class); - when(ibc.getBlockLimits()).thenReturn(map); + void testOnUnregisterIslandInWorld() { when(addon.inGameModeWorld(any())).thenReturn(true); + when(addon.getBlockLimitListener()).thenReturn(bll); + when(bll.getIsland(island.getUniqueId())).thenReturn(ibc); IslandEvent e = new IslandEvent(island, null, false, null, IslandEvent.Reason.UNREGISTERED); jl.onUnregisterIsland(e); verify(island).getWorld(); verify(addon).getBlockLimitListener(); - verify(map).clear(); - + verify(ibc).clearAllBlockLimits(); + verify(ibc).clearAllEntityLimits(); + verify(ibc).clearAllEntityGroupLimits(); } /** * Test method for {@link world.bentobox.limits.listeners.JoinListener#onUnregisterIsland(world.bentobox.bentobox.api.events.island.IslandEvent)}. */ @Test - public void testOnUnregisterIslandInWorldNullIBC() { + void testOnUnregisterIslandInWorldNullIBC() { when(bll.getIsland(anyString())).thenReturn(null); @SuppressWarnings("unchecked") Map map = mock(Map.class); - when(ibc.getBlockLimits()).thenReturn(map); + when(ibc.getBlockLimits(any(Environment.class))).thenReturn(map); when(addon.inGameModeWorld(any())).thenReturn(true); IslandEvent e = new IslandEvent(island, null, false, null, IslandEvent.Reason.UNREGISTERED); jl.onUnregisterIsland(e); @@ -550,4 +517,71 @@ public void testOnUnregisterIslandInWorldNullIBC() { } + /** + * 5-segment permission applies the limit independently to every standard env. + */ + @Test + void unprefixedPermAppliesToAllEnvs() { + Set perms = new HashSet<>(); + PermissionAttachmentInfo p = mock(PermissionAttachmentInfo.class); + when(p.getPermission()).thenReturn("bskyblock.island.limit.STONE.24"); + when(p.getValue()).thenReturn(true); + perms.add(p); + when(player.getEffectivePermissions()).thenReturn(perms); + jl.onPlayerJoin(new PlayerJoinEvent(player, Component.text("welcome"))); + verify(ibc).setBlockLimit(Environment.NORMAL, Material.STONE.getKey(), 24); + verify(ibc).setBlockLimit(Environment.NETHER, Material.STONE.getKey(), 24); + verify(ibc).setBlockLimit(Environment.THE_END, Material.STONE.getKey(), 24); + } + + /** + * 6-segment permission with `nether` env applies only to nether. + */ + @Test + void netherPrefixedPermAppliesToNetherOnly() { + Set perms = new HashSet<>(); + PermissionAttachmentInfo p = mock(PermissionAttachmentInfo.class); + when(p.getPermission()).thenReturn("bskyblock.island.limit.nether.HOPPER.5"); + when(p.getValue()).thenReturn(true); + perms.add(p); + when(player.getEffectivePermissions()).thenReturn(perms); + jl.onPlayerJoin(new PlayerJoinEvent(player, Component.text("welcome"))); + verify(ibc).setBlockLimit(Environment.NETHER, Material.HOPPER.getKey(), 5); + verify(ibc, never()).setBlockLimit(eq(Environment.NORMAL), any(NamespacedKey.class), anyInt()); + verify(ibc, never()).setBlockLimit(eq(Environment.THE_END), any(NamespacedKey.class), anyInt()); + } + + /** + * `end` and `overworld` aliases also work. + */ + @Test + void endPrefixedPermAppliesToEndOnly() { + Set perms = new HashSet<>(); + PermissionAttachmentInfo p = mock(PermissionAttachmentInfo.class); + when(p.getPermission()).thenReturn("bskyblock.island.limit.end.ENDERMAN.50"); + when(p.getValue()).thenReturn(true); + perms.add(p); + when(player.getEffectivePermissions()).thenReturn(perms); + jl.onPlayerJoin(new PlayerJoinEvent(player, Component.text("welcome"))); + verify(ibc).setEntityLimit(Environment.THE_END, EntityType.ENDERMAN, 50); + verify(ibc, never()).setEntityLimit(eq(Environment.NORMAL), any(EntityType.class), anyInt()); + verify(ibc, never()).setEntityLimit(eq(Environment.NETHER), any(EntityType.class), anyInt()); + } + + /** + * 6-segment permission with an unknown env token is rejected with a logged error. + */ + @Test + void unknownEnvPrefixIsRejected() { + Set perms = new HashSet<>(); + PermissionAttachmentInfo p = mock(PermissionAttachmentInfo.class); + when(p.getPermission()).thenReturn("bskyblock.island.limit.aether.HOPPER.5"); + when(p.getValue()).thenReturn(true); + perms.add(p); + when(player.getEffectivePermissions()).thenReturn(perms); + jl.onPlayerJoin(new PlayerJoinEvent(player, Component.text("welcome"))); + verify(addon).logError(contains("'aether' is not a recognised environment")); + verify(ibc, never()).setBlockLimit(any(Environment.class), any(NamespacedKey.class), anyInt()); + } + } diff --git a/src/test/java/world/bentobox/limits/LimitsPladdonTest.java b/src/test/java/world/bentobox/limits/LimitsPladdonTest.java index 6a74a5b..54d9f42 100644 --- a/src/test/java/world/bentobox/limits/LimitsPladdonTest.java +++ b/src/test/java/world/bentobox/limits/LimitsPladdonTest.java @@ -11,39 +11,38 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; -import static org.mockito.Mockito.spy; import static org.mockito.Mockito.mock; import world.bentobox.bentobox.api.addons.Addon; @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class LimitsPladdonTest { +class LimitsPladdonTest { private LimitsPladdon pladdon; @BeforeEach - public void setUp() { + void setUp() { // LimitsPladdon extends Pladdon extends JavaPlugin, which requires PluginClassLoader. // Use mock with CALLS_REAL_METHODS to bypass the JavaPlugin constructor. pladdon = mock(LimitsPladdon.class, org.mockito.Mockito.CALLS_REAL_METHODS); } @Test - public void testGetAddonReturnsNonNull() { + void testGetAddonReturnsNonNull() { Addon addon = pladdon.getAddon(); assertNotNull(addon); } @Test - public void testGetAddonReturnsSameInstance() { + void testGetAddonReturnsSameInstance() { Addon first = pladdon.getAddon(); Addon second = pladdon.getAddon(); assertSame(first, second); } @Test - public void testGetAddonIsInstanceOfLimits() { + void testGetAddonIsInstanceOfLimits() { Addon addon = pladdon.getAddon(); assertInstanceOf(Limits.class, addon); } diff --git a/src/test/java/world/bentobox/limits/LimitsTest.java b/src/test/java/world/bentobox/limits/LimitsTest.java index dc786a5..562e3cc 100644 --- a/src/test/java/world/bentobox/limits/LimitsTest.java +++ b/src/test/java/world/bentobox/limits/LimitsTest.java @@ -1,11 +1,11 @@ package world.bentobox.limits; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 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; @@ -56,7 +56,6 @@ import world.bentobox.bentobox.managers.IslandsManager; import world.bentobox.bentobox.managers.PlaceholdersManager; import org.mockbukkit.mockbukkit.MockBukkit; -import org.mockbukkit.mockbukkit.ServerMock; /** * @author tastybento @@ -65,7 +64,7 @@ @SuppressWarnings("deprecation") @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class LimitsTest { +class LimitsTest { private static File jFile; @Mock private User user; @@ -98,7 +97,7 @@ public class LimitsTest { private MockedStatic mockedBentoBox; @BeforeAll - public static void beforeClass() throws Exception { + static void beforeClass() throws Exception { cleanUp(); // Make the addon jar jFile = new File("addon.jar"); @@ -124,8 +123,8 @@ public static void beforeClass() throws Exception { * @throws java.lang.Exception */ @BeforeEach - public void setUp() throws Exception { - ServerMock server = MockBukkit.mock(); + void setUp() { + MockBukkit.mock(); // Set up plugin mockedBentoBox = Mockito.mockStatic(BentoBox.class); @@ -137,7 +136,6 @@ public void setUp() throws Exception { when(plugin.getSettings()).thenReturn(pluginSettings); when(pluginSettings.getDatabaseType()).thenReturn(value); - //when(plugin.isEnabled()).thenReturn(true); // Command manager CommandsManager cm = mock(CommandsManager.class); when(plugin.getCommandsManager()).thenReturn(cm); @@ -213,7 +211,7 @@ public void setUp() throws Exception { * @throws java.lang.Exception */ @AfterEach - public void tearDown() throws Exception { + void tearDown() throws Exception { if (mockedBentoBox != null) { mockedBentoBox.close(); } @@ -224,7 +222,7 @@ public void tearDown() throws Exception { } @AfterAll - public static void cleanUp() throws Exception { + static void cleanUp() throws Exception { new File("addon.jar").delete(); new File("config.yml").delete(); deleteAll(new File("addons")); @@ -243,7 +241,7 @@ private static void deleteAll(File file) throws IOException { * Test method for {@link world.bentobox.limits.Limits#onEnable()}. */ @Test - public void testOnEnable() { + void testOnEnable() { addon.onEnable(); File f = new File("config.yml"); assertTrue(f.exists()); @@ -254,26 +252,26 @@ public void testOnEnable() { * Test method for {@link world.bentobox.limits.Limits#onDisable()}. */ @Test - public void testOnDisable() { - addon.onDisable(); + void testOnDisable() { + assertDoesNotThrow(() -> addon.onDisable()); } /** * Test method for {@link world.bentobox.limits.Limits#getSettings()}. */ @Test - public void testGetSettings() { + void testGetSettings() { assertNull(addon.getSettings()); addon.onEnable(); world.bentobox.limits.Settings set = addon.getSettings(); - assertFalse(set.getLimits().isEmpty()); + assertFalse(set.getLimits(org.bukkit.World.Environment.NORMAL).isEmpty()); } /** * Test method for {@link world.bentobox.limits.Limits#getGameModes()}. */ @Test - public void testGetGameModes() { + void testGetGameModes() { assertTrue(addon.getGameModes().isEmpty()); addon.onEnable(); assertFalse(addon.getGameModes().isEmpty()); @@ -283,7 +281,7 @@ public void testGetGameModes() { * Test method for {@link world.bentobox.limits.Limits#getBlockLimitListener()}. */ @Test - public void testGetBlockLimitListener() { + void testGetBlockLimitListener() { assertNull(addon.getBlockLimitListener()); addon.onEnable(); assertNotNull(addon.getBlockLimitListener()); @@ -293,7 +291,7 @@ public void testGetBlockLimitListener() { * Test method for {@link world.bentobox.limits.Limits#inGameModeWorld(org.bukkit.World)}. */ @Test - public void testInGameModeWorld() { + void testInGameModeWorld() { addon.onEnable(); assertFalse(addon.inGameModeWorld(world)); when(gameMode.inWorld(world)).thenReturn(true); @@ -304,7 +302,7 @@ public void testInGameModeWorld() { * Test method for {@link world.bentobox.limits.Limits#getGameModeName(org.bukkit.World)}. */ @Test - public void testGetGameModeName() { + void testGetGameModeName() { when(gameMode.inWorld(world)).thenReturn(true); assertTrue(addon.getGameModeName(world).isEmpty()); addon.onEnable(); @@ -315,7 +313,7 @@ public void testGetGameModeName() { * Test method for {@link world.bentobox.limits.Limits#getGameModePermPrefix(org.bukkit.World)}. */ @Test - public void testGetGameModePermPrefix() { + void testGetGameModePermPrefix() { when(gameMode.inWorld(world)).thenReturn(true); addon.onEnable(); assertEquals("bskyblock.", addon.getGameModePermPrefix(world)); @@ -325,7 +323,7 @@ public void testGetGameModePermPrefix() { * Test method for {@link world.bentobox.limits.Limits#isCoveredGameMode(java.lang.String)}. */ @Test - public void testIsCoveredGameMode() { + void testIsCoveredGameMode() { assertFalse(addon.isCoveredGameMode("BSkyBlock")); addon.onEnable(); assertTrue(addon.isCoveredGameMode("BSkyBlock")); @@ -335,7 +333,7 @@ public void testIsCoveredGameMode() { * Test method for {@link world.bentobox.limits.Limits#getJoinListener()}. */ @Test - public void testGetJoinListener() { + void testGetJoinListener() { assertNull(addon.getJoinListener()); addon.onEnable(); assertNotNull(addon.getJoinListener()); diff --git a/src/test/java/world/bentobox/limits/SettingsTest.java b/src/test/java/world/bentobox/limits/SettingsTest.java index ec23733..3a7382d 100644 --- a/src/test/java/world/bentobox/limits/SettingsTest.java +++ b/src/test/java/world/bentobox/limits/SettingsTest.java @@ -14,6 +14,7 @@ import java.util.Map; import org.bukkit.Material; +import org.bukkit.World.Environment; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.EntityType; @@ -30,7 +31,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class SettingsTest { +class SettingsTest { @Mock private Limits addon; @@ -39,7 +40,7 @@ public class SettingsTest { private FileConfiguration config; @BeforeEach - public void setUp() throws Exception { + void setUp() throws Exception { MockBukkit.mock(); config = new YamlConfiguration(); @@ -52,12 +53,12 @@ public void setUp() throws Exception { } @AfterEach - public void tearDown() { + void tearDown() { MockBukkit.unmock(); } @Test - public void testGameModesNotEmpty() { + void testGameModesNotEmpty() { List gameModes = settings.getGameModes(); assertNotNull(gameModes); assertFalse(gameModes.isEmpty()); @@ -65,8 +66,8 @@ public void testGameModesNotEmpty() { } @Test - public void testEntityLimitsParsed() { - Map limits = settings.getLimits(); + void testEntityLimitsParsed() { + Map limits = settings.getLimits(Environment.NORMAL); assertNotNull(limits); assertFalse(limits.isEmpty()); assertEquals(5, limits.get(EntityType.ENDERMAN)); @@ -74,7 +75,7 @@ public void testEntityLimitsParsed() { } @Test - public void testGroupLimitsParsed() { + void testGroupLimitsParsed() { Map> groupLimits = settings.getGroupLimits(); assertNotNull(groupLimits); assertFalse(groupLimits.isEmpty()); @@ -83,7 +84,7 @@ public void testGroupLimitsParsed() { } @Test - public void testGetGroupLimitDefinitions() { + void testGetGroupLimitDefinitions() { List definitions = settings.getGroupLimitDefinitions(); assertNotNull(definitions); assertFalse(definitions.isEmpty()); @@ -93,17 +94,17 @@ public void testGetGroupLimitDefinitions() { } @Test - public void testLogLimitsOnJoinDefaultsTrue() { + void testLogLimitsOnJoinDefaultsTrue() { assertTrue(settings.isLogLimitsOnJoin()); } @Test - public void testAsyncGolumsDefaultsTrue() { + void testAsyncGolumsDefaultsTrue() { assertTrue(settings.isAsyncGolums()); } @Test - public void testGetGeneralEmpty() { + void testGetGeneralEmpty() { // Default config.yml does not have ANIMALS or MOBS entries in entitylimits Map general = settings.getGeneral(); assertNotNull(general); @@ -112,7 +113,7 @@ public void testGetGeneralEmpty() { } @Test - public void testGetGeneralWithAnimalsAndMobs() throws Exception { + void testGetGeneralWithAnimalsAndMobs() { // Add ANIMALS and MOBS to the entitylimits section config.set("entitylimits.ANIMALS", 100); config.set("entitylimits.MOBS", 50); @@ -123,23 +124,23 @@ public void testGetGeneralWithAnimalsAndMobs() throws Exception { } @Test - public void testUnknownEntityTypeLogsError() throws Exception { + void testUnknownEntityTypeLogsError() { config.set("entitylimits.UNKNOWN_ENTITY_XYZ", 99); Settings s = new Settings(addon); - verify(addon, atLeastOnce()).logError("Unknown entity type: UNKNOWN_ENTITY_XYZ - skipping..."); - assertFalse(s.getLimits().containsValue(99)); + verify(addon, atLeastOnce()).logError("Unknown entity type in entitylimits: UNKNOWN_ENTITY_XYZ - skipping..."); + assertFalse(s.getLimits(Environment.NORMAL).containsValue(99)); } @Test - public void testDisallowedEntityTypeLogsError() throws Exception { + void testDisallowedEntityTypeLogsError() { config.set("entitylimits.TNT", 10); Settings s = new Settings(addon); - verify(addon).logError("Entity type: TNT is not supported - skipping..."); - assertFalse(s.getLimits().containsKey(EntityType.TNT)); + verify(addon).logError("Entity type in entitylimits not supported: TNT - skipping..."); + assertFalse(s.getLimits(Environment.NORMAL).containsKey(EntityType.TNT)); } @Test - public void testInvalidGroupIconFallsBackToBarrier() throws Exception { + void testInvalidGroupIconFallsBackToBarrier() { // Clear existing groups and add one with invalid icon config.set("entitygrouplimits", null); config.set("entitygrouplimits.TestGroup.icon", "NOT_A_REAL_MATERIAL"); diff --git a/src/test/java/world/bentobox/limits/calculators/PipelinerTest.java b/src/test/java/world/bentobox/limits/calculators/PipelinerTest.java index c2c20e9..6f341c6 100644 --- a/src/test/java/world/bentobox/limits/calculators/PipelinerTest.java +++ b/src/test/java/world/bentobox/limits/calculators/PipelinerTest.java @@ -28,7 +28,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class PipelinerTest { +class PipelinerTest { @Mock private Limits addon; diff --git a/src/test/java/world/bentobox/limits/calculators/ResultsTest.java b/src/test/java/world/bentobox/limits/calculators/ResultsTest.java index a72b964..e454471 100644 --- a/src/test/java/world/bentobox/limits/calculators/ResultsTest.java +++ b/src/test/java/world/bentobox/limits/calculators/ResultsTest.java @@ -4,40 +4,41 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.bukkit.World.Environment; import org.junit.jupiter.api.Test; import world.bentobox.limits.calculators.Results.Result; -public class ResultsTest { +class ResultsTest { @Test - public void testDefaultConstructorStateIsAvailable() { + void testDefaultConstructorStateIsAvailable() { Results results = new Results(); assertEquals(Result.AVAILABLE, results.getState()); } @Test - public void testConstructorWithState() { + void testConstructorWithState() { Results results = new Results(Result.IN_PROGRESS); assertEquals(Result.IN_PROGRESS, results.getState()); } @Test - public void testGetMdCountReturnsEmptyMultiset() { + void testGetBlockCountReturnsEmptyMultiset() { Results results = new Results(); - assertNotNull(results.getMdCount()); - assertTrue(results.getMdCount().isEmpty()); + assertNotNull(results.getBlockCount(Environment.NORMAL)); + assertTrue(results.getBlockCount(Environment.NORMAL).isEmpty()); } @Test - public void testGetEntityCountReturnsEmptyMultiset() { + void testGetEntityCountReturnsEmptyMultiset() { Results results = new Results(); - assertNotNull(results.getEntityCount()); - assertTrue(results.getEntityCount().isEmpty()); + assertNotNull(results.getEntityCount(Environment.NORMAL)); + assertTrue(results.getEntityCount(Environment.NORMAL).isEmpty()); } @Test - public void testResultEnumValues() { + void testResultEnumValues() { Result[] values = Result.values(); assertEquals(3, values.length); assertNotNull(Result.valueOf("AVAILABLE")); diff --git a/src/test/java/world/bentobox/limits/commands/player/LimitPanelTest.java b/src/test/java/world/bentobox/limits/commands/player/LimitPanelTest.java index ec625ae..dd7f3d0 100644 --- a/src/test/java/world/bentobox/limits/commands/player/LimitPanelTest.java +++ b/src/test/java/world/bentobox/limits/commands/player/LimitPanelTest.java @@ -2,17 +2,15 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; 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.Map; import java.util.UUID; -import org.bukkit.NamespacedKey; import org.bukkit.World; +import org.bukkit.World.Environment; import org.bukkit.entity.Player; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -35,7 +33,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class LimitPanelTest { +class LimitPanelTest { @Mock private Limits addon; @@ -104,8 +102,8 @@ void testShowLimitsNoIslandOther() { void testShowLimitsNoLimits() { when(im.getIsland(world, targetUUID)).thenReturn(island); when(island.getUniqueId()).thenReturn("island-id"); - when(bll.getMaterialLimits(eq(world), eq("island-id"))).thenReturn(Collections.emptyMap()); - when(settings.getLimits()).thenReturn(Collections.emptyMap()); + when(bll.getMaterialLimits(world, "island-id")).thenReturn(Collections.emptyMap()); + when(settings.getLimits(Environment.NORMAL)).thenReturn(Collections.emptyMap()); // Target player is offline (MockBukkit returns null by default when no players added) limitPanel.showLimits(gm, user, targetUUID); @@ -117,8 +115,8 @@ void testShowLimitsNoLimits() { void testShowLimitsTargetPlayerOfflineDoesNotCallCheckPerms() { when(im.getIsland(world, targetUUID)).thenReturn(island); when(island.getUniqueId()).thenReturn("island-id"); - when(bll.getMaterialLimits(eq(world), eq("island-id"))).thenReturn(Collections.emptyMap()); - when(settings.getLimits()).thenReturn(Collections.emptyMap()); + when(bll.getMaterialLimits(world, "island-id")).thenReturn(Collections.emptyMap()); + when(settings.getLimits(Environment.NORMAL)).thenReturn(Collections.emptyMap()); // Target player is offline (MockBukkit returns null by default when no players added) limitPanel.showLimits(gm, user, targetUUID); diff --git a/src/test/java/world/bentobox/limits/commands/player/LimitTabTest.java b/src/test/java/world/bentobox/limits/commands/player/LimitTabTest.java index 43c2e3f..7ae6a42 100644 --- a/src/test/java/world/bentobox/limits/commands/player/LimitTabTest.java +++ b/src/test/java/world/bentobox/limits/commands/player/LimitTabTest.java @@ -1,115 +1,189 @@ package world.bentobox.limits.commands.player; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; import java.util.Collections; +import java.util.List; +import java.util.Map; -import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.World; -import org.bukkit.entity.Entity; +import org.bukkit.World.Environment; import org.bukkit.entity.EntityType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.mockbukkit.mockbukkit.MockBukkit; import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.panels.PanelItem; +import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.managers.IslandWorldManager; import world.bentobox.limits.Limits; import world.bentobox.limits.Settings; -import org.mockbukkit.mockbukkit.MockBukkit; import world.bentobox.limits.objects.IslandBlockCount; @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class LimitTabTest { - - @Mock - private Limits addon; - - private LimitTab lp; - - @Mock - private Island island; - @Mock - private World world; - @Mock - private World nether; - @Mock - private World end; - @Mock - private BentoBox plugin; - @Mock - private IslandWorldManager iwm; - @Mock - private Settings settings; +class LimitTabTest { + + @Mock private Limits addon; + @Mock private Island island; + @Mock private World world; + @Mock private BentoBox plugin; + @Mock private IslandWorldManager iwm; + @Mock private Settings settings; + @Mock private User user; @BeforeEach - public void setUp() { + void setUp() { MockBukkit.mock(); - - // Island when(island.getWorld()).thenReturn(world); - // Addon when(addon.getPlugin()).thenReturn(plugin); when(addon.getSettings()).thenReturn(settings); - when(settings.getLimits()).thenReturn(Collections.emptyMap()); when(plugin.getIWM()).thenReturn(iwm); - when(iwm.isNetherIslands(any())).thenReturn(true); - when(iwm.isEndIslands(any())).thenReturn(true); - when(iwm.getNetherWorld(eq(world))).thenReturn(nether); - when(iwm.getEndWorld(eq(world))).thenReturn(end); - // Worlds - Entity entity = mock(Entity.class); - when(entity.getType()).thenReturn(EntityType.BAT); - when(entity.getLocation()).thenReturn(mock(Location.class)); - when(world.getEntities()).thenReturn(Collections.singletonList(entity)); - when(nether.getEntities()).thenReturn(Collections.singletonList(entity)); - when(end.getEntities()).thenReturn(Collections.singletonList(entity)); - lp = new LimitTab(addon, new IslandBlockCount("", ""), Collections.emptyMap(), island, world, null, LimitTab.SORT_BY.A2Z); + when(iwm.isNetherIslands(any())).thenReturn(false); + when(iwm.isEndIslands(any())).thenReturn(false); + when(settings.getLimits(any(Environment.class))).thenReturn(Collections.emptyMap()); + when(settings.getGroupLimits(any(Environment.class))).thenReturn(Collections.emptyMap()); + when(settings.getGroupLimitDefinitions()).thenReturn(Collections.emptyList()); + // User translations: render a known set of keys to their locale templates so that + // placeholder substitution produces something we can grep in test assertions. + lenient().when(user.getTranslation(any(World.class), anyString(), any(String[].class))).thenAnswer(inv -> { + Object[] all = inv.getArguments(); + String key = (String) all[1]; + Object[] vars = new Object[all.length - 2]; + System.arraycopy(all, 2, vars, 0, vars.length); + return renderTranslation(templateFor(key), vars); + }); + lenient().when(user.getTranslation(anyString(), any(String[].class))).thenAnswer(inv -> { + Object[] all = inv.getArguments(); + String key = (String) all[0]; + Object[] vars = new Object[all.length - 1]; + System.arraycopy(all, 1, vars, 0, vars.length); + return renderTranslation(templateFor(key), vars); + }); + lenient().when(user.getTranslation(anyString())).thenAnswer(inv -> templateFor(inv.getArgument(0))); + lenient().when(user.getTranslation(any(World.class), anyString())) + .thenAnswer(inv -> templateFor(inv.getArgument(1))); } @AfterEach - public void tearDown() { + void tearDown() { MockBukkit.unmock(); } + /** Resolve a translation key to its locale template text (subset matching the en-US.yml). */ + private static String templateFor(String key) { + return switch (key) { + case "island.limits.panel.title-syntax" -> "[title] [env]"; + case "island.limits.panel.entity-name-syntax", + "island.limits.panel.entity-group-name-syntax", + "island.limits.panel.block-name-syntax" -> "[name]"; + case "island.limits.panel.env-overworld" -> "Overworld"; + case "island.limits.panel.env-nether" -> "Nether"; + case "island.limits.panel.env-end" -> "End"; + case "limits.panel-title" -> "Island limits"; + case "island.limits.block-limit-syntax" -> "[number]/[limit]"; + case "island.limits.max-color" -> "&c"; + case "island.limits.regular-color" -> "&a"; + default -> key; + }; + } + + /** Replace [name]/[number]/[limit]/[env] placeholders so test assertions can read them back. */ + private static String renderTranslation(String template, Object[] vars) { + StringBuilder out = new StringBuilder(template); + if (vars != null) { + for (int i = 0; i + 1 < vars.length; i += 2) { + String placeholder = String.valueOf(vars[i]); + String val = String.valueOf(vars[i + 1]); + int idx; + while ((idx = out.indexOf(placeholder)) >= 0) { + out.replace(idx, idx + placeholder.length(), val); + } + } + } + return out.toString(); + } + @Test - @Disabled - public void testShowLimits() { - fail("Not yet implemented"); + void blockCountReadsFromEnvKeyedIbc() { + IslandBlockCount ibc = new IslandBlockCount("island", "BSkyBlock"); + NamespacedKey hopper = Material.HOPPER.getKey(); + // Place 3 hoppers in the overworld and 7 in the nether. + for (int i = 0; i < 3; i++) ibc.add(Environment.NORMAL, hopper); + for (int i = 0; i < 7; i++) ibc.add(Environment.NETHER, hopper); + + Map matLimits = Map.of(hopper, 10); + + LimitTab overworldTab = new LimitTab(addon, ibc, matLimits, world, user, Environment.NORMAL); + LimitTab netherTab = new LimitTab(addon, ibc, matLimits, world, user, Environment.NETHER); + + assertEquals("3", findCount(overworldTab, "hopper"), "3 hoppers visible in overworld tab"); + assertEquals("7", findCount(netherTab, "hopper"), "7 hoppers visible in nether tab"); } @Test - public void testGetCountInIslandSpace() { - when(island.inIslandSpace(any(Location.class))).thenReturn(true); - EntityType ent = EntityType.BAT; - assertEquals(3L, lp.getCount(island, ent)); - ent = EntityType.GHAST; - assertEquals(0L, lp.getCount(island, ent)); - when(iwm.isEndIslands(any())).thenReturn(false); - ent = EntityType.BAT; - assertEquals(2L, lp.getCount(island, ent)); - when(iwm.isNetherIslands(any())).thenReturn(false); - ent = EntityType.BAT; - assertEquals(1L, lp.getCount(island, ent)); + void entityCountReadsFromEnvKeyedIbc() { + IslandBlockCount ibc = new IslandBlockCount("island", "BSkyBlock"); + for (int i = 0; i < 4; i++) ibc.incrementEntity(Environment.NORMAL, EntityType.CHICKEN); + for (int i = 0; i < 9; i++) ibc.incrementEntity(Environment.NETHER, EntityType.CHICKEN); + when(settings.getLimits(Environment.NORMAL)).thenReturn(Map.of(EntityType.CHICKEN, 10)); + when(settings.getLimits(Environment.NETHER)).thenReturn(Map.of(EntityType.CHICKEN, 10)); + + LimitTab overworldTab = new LimitTab(addon, ibc, Collections.emptyMap(), world, user, Environment.NORMAL); + LimitTab netherTab = new LimitTab(addon, ibc, Collections.emptyMap(), world, user, Environment.NETHER); + + assertEquals("4", findCount(overworldTab, "chicken")); + assertEquals("9", findCount(netherTab, "chicken")); } @Test - public void testGetCountNotInIslandSpace() { - EntityType ent = EntityType.BAT; - assertEquals(0L, lp.getCount(island, ent)); + void tabIconAndTitleReflectEnvironment() { + IslandBlockCount ibc = new IslandBlockCount("island", "BSkyBlock"); + LimitTab netherTab = new LimitTab(addon, ibc, Collections.emptyMap(), world, user, Environment.NETHER); + assertNotNull(netherTab.getIcon()); + assertEquals(Material.NETHERRACK, netherTab.getIcon().getItem().getType()); + assertTrue(netherTab.getName().contains("Nether"), "Title should contain 'Nether'; was: " + netherTab.getName()); + assertTrue(netherTab.getName().contains("Island limits"), + "Title should contain 'Island limits'; was: " + netherTab.getName()); } + /** + * Pull the count value (the {@code [number]} placeholder) out of the first panel-item description + * containing the given material/entity key. Returns null if not found. + */ + private static String findCount(LimitTab tab, String keyFragment) { + List items = tab.getPanelItems(); + for (PanelItem item : items) { + String desc = String.valueOf(item.getDescription()); + String name = String.valueOf(item.getName()); + if (name.toLowerCase().contains(keyFragment) || desc.toLowerCase().contains(keyFragment)) { + // description format is "[number]/[limit]" and renderTranslation replaced the placeholders + // Find the first run of digits + StringBuilder digits = new StringBuilder(); + for (int i = 0; i < desc.length(); i++) { + char c = desc.charAt(i); + if (Character.isDigit(c)) digits.append(c); + else if (!digits.isEmpty()) break; + } + return !digits.isEmpty() ? digits.toString() : null; + } + } + return null; + } } diff --git a/src/test/java/world/bentobox/limits/events/LimitsJoinPermCheckEventTest.java b/src/test/java/world/bentobox/limits/events/LimitsJoinPermCheckEventTest.java index 07254e2..f0a9333 100644 --- a/src/test/java/world/bentobox/limits/events/LimitsJoinPermCheckEventTest.java +++ b/src/test/java/world/bentobox/limits/events/LimitsJoinPermCheckEventTest.java @@ -19,7 +19,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class LimitsJoinPermCheckEventTest { +class LimitsJoinPermCheckEventTest { @Mock private Player player; @@ -28,70 +28,70 @@ public class LimitsJoinPermCheckEventTest { private LimitsJoinPermCheckEvent event; @BeforeEach - public void setUp() { + void setUp() { ibc = new IslandBlockCount("island1", "BSkyBlock"); event = new LimitsJoinPermCheckEvent(player, "island1", ibc); } @Test - public void testConstructorSetsFields() { + void testConstructorSetsFields() { assertEquals(player, event.getPlayer()); assertEquals("island1", event.getIslandId()); assertEquals(ibc, event.getIbc()); } @Test - public void testGetPlayer() { + void testGetPlayer() { assertEquals(player, event.getPlayer()); } @Test - public void testGetIslandId() { + void testGetIslandId() { assertEquals("island1", event.getIslandId()); } @Test - public void testGetSetIbc() { + void testGetSetIbc() { IslandBlockCount newIbc = new IslandBlockCount("island2", "AcidIsland"); event.setIbc(newIbc); assertEquals(newIbc, event.getIbc()); } @Test - public void testSetIbcNull() { + void testSetIbcNull() { event.setIbc(null); assertNull(event.getIbc()); } @Test - public void testIsCancelledDefaultFalse() { + void testIsCancelledDefaultFalse() { assertFalse(event.isCancelled()); } @Test - public void testSetCancelled() { + void testSetCancelled() { event.setCancelled(true); assertTrue(event.isCancelled()); } @Test - public void testIsIgnorePermsDefaultFalse() { + void testIsIgnorePermsDefaultFalse() { assertFalse(event.isIgnorePerms()); } @Test - public void testSetIgnorePerms() { + void testSetIgnorePerms() { event.setIgnorePerms(true); assertTrue(event.isIgnorePerms()); } @Test - public void testGetHandlers() { + void testGetHandlers() { assertNotNull(event.getHandlers()); } @Test - public void testGetHandlerListStatic() { + void testGetHandlerListStatic() { assertNotNull(LimitsJoinPermCheckEvent.getHandlerList()); } } diff --git a/src/test/java/world/bentobox/limits/events/LimitsPermCheckEventTest.java b/src/test/java/world/bentobox/limits/events/LimitsPermCheckEventTest.java index 5374cf9..8a53064 100644 --- a/src/test/java/world/bentobox/limits/events/LimitsPermCheckEventTest.java +++ b/src/test/java/world/bentobox/limits/events/LimitsPermCheckEventTest.java @@ -23,7 +23,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class LimitsPermCheckEventTest { +class LimitsPermCheckEventTest { @Mock private Player player; @@ -33,7 +33,7 @@ public class LimitsPermCheckEventTest { private LimitsPermCheckEvent event; @BeforeEach - public void setUp() { + void setUp() { MockBukkit.mock(); ibc = new IslandBlockCount("island1", "BSkyBlock"); entityGroup = new EntityGroup("monsters", Set.of(EntityType.ZOMBIE), 10, Material.ZOMBIE_HEAD); @@ -41,12 +41,12 @@ public void setUp() { } @AfterEach - public void tearDown() { + void tearDown() { MockBukkit.unmock(); } @Test - public void testConstructorSetsAllFields() { + void testConstructorSetsAllFields() { assertEquals(player, event.getPlayer()); assertEquals("island1", event.getIslandId()); assertEquals(ibc, event.getIbc()); @@ -57,44 +57,44 @@ public void testConstructorSetsAllFields() { } @Test - public void testGetSetEntityGroup() { + void testGetSetEntityGroup() { EntityGroup newGroup = new EntityGroup("animals", Set.of(EntityType.COW), 5, null); event.setEntityGroup(newGroup); assertEquals(newGroup, event.getEntityGroup()); } @Test - public void testGetSetEntityType() { + void testGetSetEntityType() { event.setEntityType(EntityType.SKELETON); assertEquals(EntityType.SKELETON, event.getEntityType()); } @Test - public void testGetSetMaterial() { + void testGetSetMaterial() { event.setMaterial(Material.DIAMOND_BLOCK); assertEquals(Material.DIAMOND_BLOCK, event.getMaterial()); } @Test - public void testGetSetValue() { + void testGetSetValue() { event.setValue(100); assertEquals(100, event.getValue()); } @Test - public void testNullEntityGroup() { + void testNullEntityGroup() { event.setEntityGroup(null); assertNull(event.getEntityGroup()); } @Test - public void testNullEntityType() { + void testNullEntityType() { event.setEntityType(null); assertNull(event.getEntityType()); } @Test - public void testNullMaterial() { + void testNullMaterial() { event.setMaterial(null); assertNull(event.getMaterial()); } diff --git a/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java b/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java index 94ef373..3331193 100644 --- a/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java +++ b/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java @@ -7,7 +7,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; @@ -28,6 +27,7 @@ import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.World; +import org.bukkit.World.Environment; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.BlockState; @@ -85,7 +85,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class BlockLimitsListenerTest { +class BlockLimitsListenerTest { @Mock private Limits addon; @@ -117,7 +117,7 @@ public class BlockLimitsListenerTest { @SuppressWarnings("unchecked") @BeforeEach - public void setUp() throws Exception { + void setUp() throws Exception { MockBukkit.mock(); // Set up BentoBox static mock so Database class can initialize @@ -147,6 +147,7 @@ public void setUp() throws Exception { blockLocation = new Location(world, 100, 65, 100); centerLocation = new Location(world, 0, 65, 0); when(island.getCenter()).thenReturn(centerLocation); + when(world.getEnvironment()).thenReturn(Environment.NORMAL); when(addon.getGameModeName(world)).thenReturn("BSkyBlock"); @@ -169,7 +170,7 @@ public void setUp() throws Exception { } @AfterEach - public void tearDown() { + void tearDown() { User.clearUsers(); if (mockedDb != null) { mockedDb.close(); @@ -183,49 +184,49 @@ public void tearDown() { // --- fixMaterial tests --- @Test - public void testFixMaterialChippedAnvil() { + void testFixMaterialChippedAnvil() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.CHIPPED_ANVIL); assertEquals(Material.ANVIL.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialDamagedAnvil() { + void testFixMaterialDamagedAnvil() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.DAMAGED_ANVIL); assertEquals(Material.ANVIL.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialRedstoneWallTorch() { + void testFixMaterialRedstoneWallTorch() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.REDSTONE_WALL_TORCH); assertEquals(Material.REDSTONE_TORCH.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialWallTorch() { + void testFixMaterialWallTorch() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.WALL_TORCH); assertEquals(Material.TORCH.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialZombieWallHead() { + void testFixMaterialZombieWallHead() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.ZOMBIE_WALL_HEAD); assertEquals(Material.ZOMBIE_HEAD.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialBambooSapling() { + void testFixMaterialBambooSapling() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.BAMBOO_SAPLING); assertEquals(Material.BAMBOO.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialPistonHeadNormal() { + void testFixMaterialPistonHeadNormal() { TechnicalPiston tp = mock(TechnicalPiston.class); when(tp.getMaterial()).thenReturn(Material.PISTON_HEAD); when(tp.getType()).thenReturn(TechnicalPiston.Type.NORMAL); @@ -233,7 +234,7 @@ public void testFixMaterialPistonHeadNormal() { } @Test - public void testFixMaterialPistonHeadSticky() { + void testFixMaterialPistonHeadSticky() { TechnicalPiston tp = mock(TechnicalPiston.class); when(tp.getMaterial()).thenReturn(Material.PISTON_HEAD); when(tp.getType()).thenReturn(TechnicalPiston.Type.STICKY); @@ -241,70 +242,70 @@ public void testFixMaterialPistonHeadSticky() { } @Test - public void testFixMaterialRegularStone() { + void testFixMaterialRegularStone() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.STONE); assertEquals(Material.STONE.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialCopperWallTorch() { + void testFixMaterialCopperWallTorch() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.COPPER_WALL_TORCH); assertEquals(Material.COPPER_TORCH.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialExposedCopperChest() { + void testFixMaterialExposedCopperChest() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.EXPOSED_COPPER_CHEST); assertEquals(Material.COPPER_CHEST.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialWeatheredCopperChest() { + void testFixMaterialWeatheredCopperChest() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.WEATHERED_COPPER_CHEST); assertEquals(Material.COPPER_CHEST.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialOxidizedCopperChest() { + void testFixMaterialOxidizedCopperChest() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.OXIDIZED_COPPER_CHEST); assertEquals(Material.COPPER_CHEST.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialWaxedCopperChest() { + void testFixMaterialWaxedCopperChest() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.WAXED_COPPER_CHEST); assertEquals(Material.COPPER_CHEST.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialWaxedExposedCopperChest() { + void testFixMaterialWaxedExposedCopperChest() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.WAXED_EXPOSED_COPPER_CHEST); assertEquals(Material.COPPER_CHEST.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialWaxedWeatheredCopperChest() { + void testFixMaterialWaxedWeatheredCopperChest() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.WAXED_WEATHERED_COPPER_CHEST); assertEquals(Material.COPPER_CHEST.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialWaxedOxidizedCopperChest() { + void testFixMaterialWaxedOxidizedCopperChest() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.WAXED_OXIDIZED_COPPER_CHEST); assertEquals(Material.COPPER_CHEST.getKey(), listener.fixMaterial(blockData)); } @Test - public void testFixMaterialCopperChestUnchanged() { + void testFixMaterialCopperChestUnchanged() { BlockData blockData = mock(BlockData.class); when(blockData.getMaterial()).thenReturn(Material.COPPER_CHEST); assertEquals(Material.COPPER_CHEST.getKey(), listener.fixMaterial(blockData)); @@ -313,7 +314,7 @@ public void testFixMaterialCopperChestUnchanged() { // --- setIsland / getIsland tests --- @Test - public void testSetAndGetIsland() { + void testSetAndGetIsland() { String islandId = "test-island-123"; IslandBlockCount ibc = new IslandBlockCount(islandId, "BSkyBlock"); listener.setIsland(islandId, ibc); @@ -323,14 +324,14 @@ public void testSetAndGetIsland() { } @Test - public void testGetIslandUnknownIdReturnsNull() { + void testGetIslandUnknownIdReturnsNull() { assertNull(listener.getIsland("nonexistent-island-id")); } // --- getMaterialLimits tests --- @Test - public void testGetMaterialLimitsDefaultOnly() { + void testGetMaterialLimitsDefaultOnly() { Map limits = listener.getMaterialLimits(world, "some-island"); assertNotNull(limits); // The default config has HOPPER: 10 in blocklimits @@ -339,10 +340,10 @@ public void testGetMaterialLimitsDefaultOnly() { } @Test - public void testGetMaterialLimitsIslandOverridesDefault() { + void testGetMaterialLimitsIslandOverridesDefault() { String islandId = "island-override-test"; IslandBlockCount ibc = new IslandBlockCount(islandId, "BSkyBlock"); - ibc.setBlockLimit(Material.HOPPER.getKey(), 25); + ibc.setBlockLimit(Environment.NORMAL, Material.HOPPER.getKey(), 25); listener.setIsland(islandId, ibc); Map limits = listener.getMaterialLimits(world, islandId); @@ -371,7 +372,7 @@ private Block mockBlock(Material material, Location location) { // --- BlockPlaceEvent tests --- @Test - public void testBlockPlaceIncrementsCount() { + void testBlockPlaceIncrementsCount() { Block block = mockBlock(Material.STONE, blockLocation); BlockState replacedState = mock(BlockState.class); BlockPlaceEvent event = new BlockPlaceEvent(block, replacedState, block, new ItemStack(Material.STONE), player, true, EquipmentSlot.HAND); @@ -384,11 +385,11 @@ public void testBlockPlaceIncrementsCount() { } @Test - public void testBlockPlaceAtLimitCancelsEvent() { + void testBlockPlaceAtLimitCancelsEvent() { // Pre-populate island with 10 HOPPERs (default config limit is 10) IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); for (int i = 0; i < 10; i++) { - ibc.add(Material.HOPPER.getKey()); + ibc.add(Environment.NORMAL, Material.HOPPER.getKey()); } listener.setIsland("test-island-id", ibc); @@ -402,7 +403,7 @@ public void testBlockPlaceAtLimitCancelsEvent() { } @Test - public void testBlockPlaceUnlimitedMaterialAllowed() { + void testBlockPlaceUnlimitedMaterialAllowed() { Block block = mockBlock(Material.DIRT, blockLocation); BlockState replacedState = mock(BlockState.class); BlockPlaceEvent event = new BlockPlaceEvent(block, replacedState, block, new ItemStack(Material.DIRT), player, true, EquipmentSlot.HAND); @@ -413,7 +414,7 @@ public void testBlockPlaceUnlimitedMaterialAllowed() { } @Test - public void testBlockPlaceDoNotCountWaterBlock() { + void testBlockPlaceDoNotCountWaterBlock() { Block block = mockBlock(Material.WATER, blockLocation); BlockState replacedState = mock(BlockState.class); BlockPlaceEvent event = new BlockPlaceEvent(block, replacedState, block, new ItemStack(Material.WATER_BUCKET), player, true, EquipmentSlot.HAND); @@ -425,7 +426,7 @@ public void testBlockPlaceDoNotCountWaterBlock() { } @Test - public void testBlockPlaceOutsideGameModeWorldIgnored() { + void testBlockPlaceOutsideGameModeWorldIgnored() { when(addon.inGameModeWorld(world)).thenReturn(false); Block block = mockBlock(Material.STONE, blockLocation); @@ -440,12 +441,12 @@ public void testBlockPlaceOutsideGameModeWorldIgnored() { // --- BlockBreakEvent tests --- @Test - public void testBlockBreakDecrementsCount() { + void testBlockBreakDecrementsCount() { // Pre-populate with 3 STONE IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.STONE.getKey()); - ibc.add(Material.STONE.getKey()); - ibc.add(Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.STONE, blockLocation); @@ -457,7 +458,7 @@ public void testBlockBreakDecrementsCount() { } @Test - public void testBlockBreakCountNeverGoesNegative() { + void testBlockBreakCountNeverGoesNegative() { // Start with 0 STONE (no pre-population) IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); listener.setIsland("test-island-id", ibc); @@ -471,10 +472,10 @@ public void testBlockBreakCountNeverGoesNegative() { } @Test - public void testBlockBreakWithMetadataIgnoreFlagSkipped() { + void testBlockBreakWithMetadataIgnoreFlagSkipped() { // Pre-populate with 1 STONE IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.STONE, blockLocation); @@ -490,11 +491,11 @@ public void testBlockBreakWithMetadataIgnoreFlagSkipped() { // --- BlockMultiPlaceEvent tests --- @Test - public void testBlockMultiPlaceAtLimitCancels() { + void testBlockMultiPlaceAtLimitCancels() { // Set limit for OAK_PLANKS to 1 via island-specific limit IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.setBlockLimit(Material.OAK_PLANKS.getKey(), 1); - ibc.add(Material.OAK_PLANKS.getKey()); + ibc.setBlockLimit(Environment.NORMAL, Material.OAK_PLANKS.getKey(), 1); + ibc.add(Environment.NORMAL, Material.OAK_PLANKS.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.OAK_PLANKS, blockLocation); @@ -502,17 +503,62 @@ public void testBlockMultiPlaceAtLimitCancels() { when(state.getBlock()).thenReturn(block); List states = List.of(state); Block clicked = mockBlock(Material.DIRT, blockLocation); - BlockMultiPlaceEvent event = new BlockMultiPlaceEvent(states, clicked, new ItemStack(Material.OAK_PLANKS), player, true); + BlockMultiPlaceEvent event = new BlockMultiPlaceEvent(states, clicked, new ItemStack(Material.OAK_PLANKS), player, + true, EquipmentSlot.HAND); listener.onBlock(event); assertTrue(event.isCancelled()); } + @Test + void testBedPlacedViaMultiPlaceCountsOnce() { + // Regression for #86. A bed places two blocks and fires a BlockMultiPlaceEvent. + // BlockMultiPlaceEvent shares BlockPlaceEvent's HandlerList, so a single dispatch + // reaches BOTH onBlock(BlockPlaceEvent) and onBlock(BlockMultiPlaceEvent). The bed + // must be counted once, not twice. Dispatch through the real plugin manager so the + // double-delivery actually happens (a direct handler call would mask it). + org.mockbukkit.mockbukkit.plugin.PluginMock mockPlugin = MockBukkit.createMockPlugin(); + org.bukkit.Bukkit.getPluginManager().registerEvents(listener, mockPlugin); + + Block foot = mockBlock(Material.RED_BED, blockLocation); + Block head = mockBlock(Material.RED_BED, new Location(world, 100, 65, 101)); + BlockState headState = mock(BlockState.class); + when(headState.getBlock()).thenReturn(head); + BlockMultiPlaceEvent event = new BlockMultiPlaceEvent(List.of(headState), foot, + new ItemStack(Material.RED_BED), player, true, EquipmentSlot.HAND); + + org.bukkit.Bukkit.getPluginManager().callEvent(event); + + assertEquals(1, listener.getIsland("test-island-id").getBlockCount(Material.RED_BED.getKey())); + } + + @Test + void testBedPlaceThenBreakLeavesZero() { + // Regression for #86: placing then breaking a bed must return the count to 0, + // not leave a phantom bed behind. + org.mockbukkit.mockbukkit.plugin.PluginMock mockPlugin = MockBukkit.createMockPlugin(); + org.bukkit.Bukkit.getPluginManager().registerEvents(listener, mockPlugin); + + Block foot = mockBlock(Material.RED_BED, blockLocation); + Block head = mockBlock(Material.RED_BED, new Location(world, 100, 65, 101)); + BlockState headState = mock(BlockState.class); + when(headState.getBlock()).thenReturn(head); + BlockMultiPlaceEvent place = new BlockMultiPlaceEvent(List.of(headState), foot, + new ItemStack(Material.RED_BED), player, true, EquipmentSlot.HAND); + org.bukkit.Bukkit.getPluginManager().callEvent(place); + + // Player breaks one half of the bed (vanilla removes the other half with no break event). + BlockBreakEvent breakEvent = new BlockBreakEvent(foot, player); + org.bukkit.Bukkit.getPluginManager().callEvent(breakEvent); + + assertEquals(0, listener.getIsland("test-island-id").getBlockCount(Material.RED_BED.getKey())); + } + // --- PlayerInteractEvent tests --- @Test - public void testTurtleEggNonPhysicalActionIgnored() { + void testTurtleEggNonPhysicalActionIgnored() { Block block = mockBlock(Material.TURTLE_EGG, blockLocation); PlayerInteractEvent event = new PlayerInteractEvent(player, Action.LEFT_CLICK_BLOCK, null, block, BlockFace.UP); @@ -525,10 +571,10 @@ public void testTurtleEggNonPhysicalActionIgnored() { // --- BlockBurnEvent / BlockFadeEvent / LeavesDecayEvent tests --- @Test - public void testBlockBurnDecrementsCount() { + void testBlockBurnDecrementsCount() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.OAK_PLANKS.getKey()); - ibc.add(Material.OAK_PLANKS.getKey()); + ibc.add(Environment.NORMAL, Material.OAK_PLANKS.getKey()); + ibc.add(Environment.NORMAL, Material.OAK_PLANKS.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.OAK_PLANKS, blockLocation); @@ -540,10 +586,10 @@ public void testBlockBurnDecrementsCount() { } @Test - public void testBlockFadeDecrementsCount() { + void testBlockFadeDecrementsCount() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.SAND.getKey()); - ibc.add(Material.SAND.getKey()); + ibc.add(Environment.NORMAL, Material.SAND.getKey()); + ibc.add(Environment.NORMAL, Material.SAND.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.SAND, blockLocation); @@ -556,10 +602,10 @@ public void testBlockFadeDecrementsCount() { } @Test - public void testLeavesDecayDecrementsCount() { + void testLeavesDecayDecrementsCount() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.OAK_LEAVES.getKey()); - ibc.add(Material.OAK_LEAVES.getKey()); + ibc.add(Environment.NORMAL, Material.OAK_LEAVES.getKey()); + ibc.add(Environment.NORMAL, Material.OAK_LEAVES.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.OAK_LEAVES, blockLocation); @@ -573,10 +619,13 @@ public void testLeavesDecayDecrementsCount() { // --- BlockSpreadEvent tests --- @Test - public void testBlockSpreadIncrementsCount() { + void testBlockSpreadIncrementsCount() { Block block = mockBlock(Material.GRASS_BLOCK, blockLocation); Block source = mockBlock(Material.GRASS_BLOCK, new Location(world, 101, 65, 100)); BlockState newState = mock(BlockState.class); + BlockData newBlockData = mock(BlockData.class); + when(newBlockData.getMaterial()).thenReturn(Material.GRASS_BLOCK); + when(newState.getBlockData()).thenReturn(newBlockData); BlockSpreadEvent event = new BlockSpreadEvent(block, source, newState); listener.onBlock(event); @@ -586,10 +635,56 @@ public void testBlockSpreadIncrementsCount() { assertEquals(1, ibc.getBlockCount(Material.GRASS_BLOCK.getKey())); } + @Test + void testBlockSpreadDecrementsOldAddsNew() { + // Grass spreading onto dirt: DIRT count should drop, GRASS_BLOCK count should rise. + IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); + ibc.add(Environment.NORMAL, Material.DIRT.getKey()); + listener.setIsland("test-island-id", ibc); + + Block block = mockBlock(Material.DIRT, blockLocation); + Block source = mockBlock(Material.GRASS_BLOCK, new Location(world, 101, 65, 100)); + BlockState newState = mock(BlockState.class); + BlockData newBlockData = mock(BlockData.class); + when(newBlockData.getMaterial()).thenReturn(Material.GRASS_BLOCK); + when(newState.getBlockData()).thenReturn(newBlockData); + BlockSpreadEvent event = new BlockSpreadEvent(block, source, newState); + + listener.onBlock(event); + + assertFalse(event.isCancelled()); + assertEquals(0, ibc.getBlockCount(Material.DIRT.getKey())); + assertEquals(1, ibc.getBlockCount(Material.GRASS_BLOCK.getKey())); + } + + @Test + void testBlockSpreadAtLimitCancelsAndRestoresOld() { + // GRASS_BLOCK is at limit; spread onto DIRT must be cancelled and the DIRT count preserved. + IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); + ibc.add(Environment.NORMAL, Material.DIRT.getKey()); + ibc.setBlockLimit(Environment.NORMAL, Material.GRASS_BLOCK.getKey(), 1); + ibc.add(Environment.NORMAL, Material.GRASS_BLOCK.getKey()); + listener.setIsland("test-island-id", ibc); + + Block block = mockBlock(Material.DIRT, blockLocation); + Block source = mockBlock(Material.GRASS_BLOCK, new Location(world, 101, 65, 100)); + BlockState newState = mock(BlockState.class); + BlockData newBlockData = mock(BlockData.class); + when(newBlockData.getMaterial()).thenReturn(Material.GRASS_BLOCK); + when(newState.getBlockData()).thenReturn(newBlockData); + BlockSpreadEvent event = new BlockSpreadEvent(block, source, newState); + + listener.onBlock(event); + + assertTrue(event.isCancelled()); + assertEquals(1, ibc.getBlockCount(Material.DIRT.getKey())); + assertEquals(1, ibc.getBlockCount(Material.GRASS_BLOCK.getKey())); + } + // --- BlockFromToEvent tests --- @Test - public void testBlockFromToNonLiquidIgnored() { + void testBlockFromToNonLiquidIgnored() { Block sourceBlock = mockBlock(Material.STONE, blockLocation); when(sourceBlock.isLiquid()).thenReturn(false); Block toBlock = mockBlock(Material.REDSTONE_WIRE, new Location(world, 101, 65, 100)); @@ -604,7 +699,7 @@ public void testBlockFromToNonLiquidIgnored() { // --- BlockFormEvent / EntityBlockFormEvent tests --- @Test - public void testBlockFormIgnoresEntityBlockFormEvent() { + void testBlockFormIgnoresEntityBlockFormEvent() { Block block = mockBlock(Material.STONE, blockLocation); BlockState newState = mock(BlockState.class); Entity entity = mock(Entity.class); @@ -620,9 +715,9 @@ public void testBlockFormIgnoresEntityBlockFormEvent() { // --- Block state transition tests --- @Test - public void testBlockFormStateTransition() { + void testBlockFormStateTransition() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.COBBLESTONE, blockLocation); @@ -640,11 +735,11 @@ public void testBlockFormStateTransition() { } @Test - public void testBlockFormAtLimitCancelsAndRestoresOld() { + void testBlockFormAtLimitCancelsAndRestoresOld() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.COBBLESTONE.getKey()); - ibc.setBlockLimit(Material.STONE.getKey(), 1); - ibc.add(Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); + ibc.setBlockLimit(Environment.NORMAL, Material.STONE.getKey(), 1); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.COBBLESTONE, blockLocation); @@ -662,9 +757,9 @@ public void testBlockFormAtLimitCancelsAndRestoresOld() { } @Test - public void testEntityBlockFormStateTransition() { + void testEntityBlockFormStateTransition() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.COBBLESTONE, blockLocation); @@ -685,9 +780,9 @@ public void testEntityBlockFormStateTransition() { // --- BlockGrowEvent tests --- @Test - public void testBlockGrowIncrementsNewAndRemovesOld() { + void testBlockGrowIncrementsNewAndRemovesOld() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.DIRT.getKey()); + ibc.add(Environment.NORMAL, Material.DIRT.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.DIRT, blockLocation); @@ -704,10 +799,10 @@ public void testBlockGrowIncrementsNewAndRemovesOld() { } @Test - public void testBlockGrowAtLimitCancelsAndRestoresBlockData() { + void testBlockGrowAtLimitCancelsAndRestoresBlockData() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.DIRT.getKey()); - ibc.setBlockLimit(Material.GRASS_BLOCK.getKey(), 0); + ibc.add(Environment.NORMAL, Material.DIRT.getKey()); + ibc.setBlockLimit(Environment.NORMAL, Material.GRASS_BLOCK.getKey(), 0); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.DIRT, blockLocation); @@ -728,12 +823,38 @@ public void testBlockGrowAtLimitCancelsAndRestoresBlockData() { verify(worldBlock).setBlockData(block.getBlockData()); } + @Test + void testBlockGrowAtNonZeroLimitDoesNotDecrement() { + // GRASS_BLOCK is at a non-zero limit (5 of 5). A blocked grow must NOT change the count. + IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); + ibc.setBlockLimit(Environment.NORMAL, Material.GRASS_BLOCK.getKey(), 5); + for (int i = 0; i < 5; i++) { + ibc.add(Environment.NORMAL, Material.GRASS_BLOCK.getKey()); + } + listener.setIsland("test-island-id", ibc); + + Block block = mockBlock(Material.DIRT, blockLocation); + Block newBlock = mockBlock(Material.GRASS_BLOCK, blockLocation); + BlockState newState = mock(BlockState.class); + when(newState.getBlock()).thenReturn(newBlock); + + Block worldBlock = mock(Block.class); + when(world.getBlockAt(blockLocation)).thenReturn(worldBlock); + + BlockGrowEvent event = new BlockGrowEvent(block, newState); + listener.onBlock(event); + + assertTrue(event.isCancelled()); + // The physical world still has 5 grass blocks, so the count must remain 5. + assertEquals(5, ibc.getBlockCount(Material.GRASS_BLOCK.getKey())); + } + // --- EntityChangeBlockEvent state transition tests --- @Test - public void testEntityChangeBlockToNonAirAddsNewRemovesOld() { + void testEntityChangeBlockToNonAirAddsNewRemovesOld() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.DIRT.getKey()); + ibc.add(Environment.NORMAL, Material.DIRT.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.DIRT, blockLocation); @@ -751,11 +872,11 @@ public void testEntityChangeBlockToNonAirAddsNewRemovesOld() { } @Test - public void testEntityChangeBlockToNonAirAtLimitCancels() { + void testEntityChangeBlockToNonAirAtLimitCancels() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.DIRT.getKey()); - ibc.setBlockLimit(Material.COBBLESTONE.getKey(), 1); - ibc.add(Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.DIRT.getKey()); + ibc.setBlockLimit(Environment.NORMAL, Material.COBBLESTONE.getKey(), 1); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.DIRT, blockLocation); @@ -773,10 +894,10 @@ public void testEntityChangeBlockToNonAirAtLimitCancels() { } @Test - public void testEntityChangeBlockFarmlandRemovesCropAbove() { + void testEntityChangeBlockFarmlandRemovesCropAbove() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.FARMLAND.getKey()); - ibc.add(Material.OAK_PLANKS.getKey()); + ibc.add(Environment.NORMAL, Material.FARMLAND.getKey()); + ibc.add(Environment.NORMAL, Material.OAK_PLANKS.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.FARMLAND, blockLocation); @@ -800,11 +921,11 @@ public void testEntityChangeBlockFarmlandRemovesCropAbove() { // --- Block cascade tests --- @Test - public void testBlockBreakSugarCaneCascade() { + void testBlockBreakSugarCaneCascade() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.SUGAR_CANE.getKey()); - ibc.add(Material.SUGAR_CANE.getKey()); - ibc.add(Material.SUGAR_CANE.getKey()); + ibc.add(Environment.NORMAL, Material.SUGAR_CANE.getKey()); + ibc.add(Environment.NORMAL, Material.SUGAR_CANE.getKey()); + ibc.add(Environment.NORMAL, Material.SUGAR_CANE.getKey()); listener.setIsland("test-island-id", ibc); when(world.getMaxHeight()).thenReturn(320); @@ -828,11 +949,11 @@ public void testBlockBreakSugarCaneCascade() { } @Test - public void testBlockBreakBambooCascade() { + void testBlockBreakBambooCascade() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.BAMBOO.getKey()); - ibc.add(Material.BAMBOO.getKey()); - ibc.add(Material.BAMBOO.getKey()); + ibc.add(Environment.NORMAL, Material.BAMBOO.getKey()); + ibc.add(Environment.NORMAL, Material.BAMBOO.getKey()); + ibc.add(Environment.NORMAL, Material.BAMBOO.getKey()); listener.setIsland("test-island-id", ibc); when(world.getMaxHeight()).thenReturn(320); @@ -854,10 +975,10 @@ public void testBlockBreakBambooCascade() { } @Test - public void testBlockBreakRedstoneOnTopRemoved() { + void testBlockBreakRedstoneOnTopRemoved() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.STONE.getKey()); - ibc.add(Material.REDSTONE_WIRE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.REDSTONE_WIRE.getKey()); listener.setIsland("test-island-id", ibc); Block stoneBlock = mockBlock(Material.STONE, blockLocation); @@ -872,11 +993,11 @@ public void testBlockBreakRedstoneOnTopRemoved() { } @Test - public void testBlockBreakRedstoneWallTorchOnSideRemoved() { + void testBlockBreakRedstoneWallTorchOnSideRemoved() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); // fixMaterial normalises REDSTONE_WALL_TORCH → REDSTONE_TORCH - ibc.add(Material.REDSTONE_TORCH.getKey()); + ibc.add(Environment.NORMAL, Material.REDSTONE_TORCH.getKey()); listener.setIsland("test-island-id", ibc); Block stoneBlock = mockBlock(Material.STONE, blockLocation); @@ -893,7 +1014,7 @@ public void testBlockBreakRedstoneWallTorchOnSideRemoved() { // --- Center block test --- @Test - public void testBlockPlaceCenterBlockIgnored() { + void testBlockPlaceCenterBlockIgnored() { // Make the block location equal to the island center when(island.getCenter()).thenReturn(blockLocation); @@ -910,9 +1031,9 @@ public void testBlockPlaceCenterBlockIgnored() { // --- Turtle egg physical interaction test --- @Test - public void testTurtleEggPhysicalBreakDecrementsCount() { + void testTurtleEggPhysicalBreakDecrementsCount() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.TURTLE_EGG.getKey()); + ibc.add(Environment.NORMAL, Material.TURTLE_EGG.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.TURTLE_EGG, blockLocation); @@ -926,11 +1047,11 @@ public void testTurtleEggPhysicalBreakDecrementsCount() { // --- Explosion tests --- @Test - public void testBlockExplodeDecrementsBatch() { + void testBlockExplodeDecrementsBatch() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.STONE.getKey()); - ibc.add(Material.STONE.getKey()); - ibc.add(Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); listener.setIsland("test-island-id", ibc); List blocks = List.of( @@ -947,11 +1068,11 @@ public void testBlockExplodeDecrementsBatch() { } @Test - public void testEntityExplodeDecrementsBatch() { + void testEntityExplodeDecrementsBatch() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.STONE.getKey()); - ibc.add(Material.STONE.getKey()); - ibc.add(Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); listener.setIsland("test-island-id", ibc); List blocks = List.of( @@ -969,9 +1090,9 @@ public void testEntityExplodeDecrementsBatch() { // --- EntityChangeBlockEvent tests --- @Test - public void testEntityChangeBlockToAirDecrements() { + void testEntityChangeBlockToAirDecrements() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.STONE.getKey()); + ibc.add(Environment.NORMAL, Material.STONE.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.STONE, blockLocation); @@ -988,9 +1109,9 @@ public void testEntityChangeBlockToAirDecrements() { // --- BlockFromToEvent liquid destroys redstone test --- @Test - public void testBlockFromToLiquidDestroysRedstone() { + void testBlockFromToLiquidDestroysRedstone() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.REDSTONE_WIRE.getKey()); + ibc.add(Environment.NORMAL, Material.REDSTONE_WIRE.getKey()); listener.setIsland("test-island-id", ibc); Block sourceBlock = mockBlock(Material.WATER, blockLocation); @@ -1006,7 +1127,7 @@ public void testBlockFromToLiquidDestroysRedstone() { // --- Limit hierarchy tests --- @Test - public void testIslandLimitTakesPrecedenceOverWorldLimit() throws Exception { + void testIslandLimitTakesPrecedenceOverWorldLimit() throws Exception { // Set world limit for COBBLESTONE = 5 Field worldLimitField = BlockLimitsListener.class.getDeclaredField("worldLimitMap"); worldLimitField.setAccessible(true); @@ -1019,9 +1140,9 @@ public void testIslandLimitTakesPrecedenceOverWorldLimit() throws Exception { // Set island-specific limit for COBBLESTONE = 2, pre-populate with 2 IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.setBlockLimit(Material.COBBLESTONE.getKey(), 2); - ibc.add(Material.COBBLESTONE.getKey()); - ibc.add(Material.COBBLESTONE.getKey()); + ibc.setBlockLimit(Environment.NORMAL, Material.COBBLESTONE.getKey(), 2); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.COBBLESTONE, blockLocation); @@ -1034,14 +1155,9 @@ public void testIslandLimitTakesPrecedenceOverWorldLimit() throws Exception { } @Test - public void testWorldLimitTakesPrecedenceOverDefaultLimit() throws Exception { + void testWorldLimitTakesPrecedenceOverDefaultLimit() throws Exception { // Set default limit for COBBLESTONE = 10 - Field defaultLimitField = BlockLimitsListener.class.getDeclaredField("defaultLimitMap"); - defaultLimitField.setAccessible(true); - @SuppressWarnings("unchecked") - Map defaultLimitMap = - (Map) defaultLimitField.get(listener); - defaultLimitMap.put(Material.COBBLESTONE.getKey(), 10); + listener.getEnvDefaultLimitMap().get(Environment.NORMAL).put(Material.COBBLESTONE.getKey(), 10); // Set world limit for COBBLESTONE = 3 Field worldLimitField = BlockLimitsListener.class.getDeclaredField("worldLimitMap"); @@ -1055,9 +1171,9 @@ public void testWorldLimitTakesPrecedenceOverDefaultLimit() throws Exception { // No island-specific limit; pre-populate with 3 IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.COBBLESTONE.getKey()); - ibc.add(Material.COBBLESTONE.getKey()); - ibc.add(Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.COBBLESTONE, blockLocation); @@ -1070,19 +1186,14 @@ public void testWorldLimitTakesPrecedenceOverDefaultLimit() throws Exception { } @Test - public void testDefaultLimitAppliedWhenNoIslandOrWorldLimit() throws Exception { + void testDefaultLimitAppliedWhenNoIslandOrWorldLimit() { // Set default limit for COBBLESTONE = 2 - Field defaultLimitField = BlockLimitsListener.class.getDeclaredField("defaultLimitMap"); - defaultLimitField.setAccessible(true); - @SuppressWarnings("unchecked") - Map defaultLimitMap = - (Map) defaultLimitField.get(listener); - defaultLimitMap.put(Material.COBBLESTONE.getKey(), 2); + listener.getEnvDefaultLimitMap().get(Environment.NORMAL).put(Material.COBBLESTONE.getKey(), 2); // No island or world limit; pre-populate with 2 IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.add(Material.COBBLESTONE.getKey()); - ibc.add(Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.COBBLESTONE, blockLocation); @@ -1095,22 +1206,17 @@ public void testDefaultLimitAppliedWhenNoIslandOrWorldLimit() throws Exception { } @Test - public void testIslandOffsetIncreasesEffectiveLimit() throws Exception { + void testIslandOffsetIncreasesEffectiveLimit() { // Set default limit for COBBLESTONE = 2 - Field defaultLimitField = BlockLimitsListener.class.getDeclaredField("defaultLimitMap"); - defaultLimitField.setAccessible(true); - @SuppressWarnings("unchecked") - Map defaultLimitMap = - (Map) defaultLimitField.get(listener); - defaultLimitMap.put(Material.COBBLESTONE.getKey(), 2); + listener.getEnvDefaultLimitMap().get(Environment.NORMAL).put(Material.COBBLESTONE.getKey(), 2); // Set island offset = +3 (effective limit = 5); pre-populate with 4 IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); - ibc.setBlockLimitsOffset(Material.COBBLESTONE.getKey(), 3); - ibc.add(Material.COBBLESTONE.getKey()); - ibc.add(Material.COBBLESTONE.getKey()); - ibc.add(Material.COBBLESTONE.getKey()); - ibc.add(Material.COBBLESTONE.getKey()); + ibc.setBlockLimitsOffset(Environment.NORMAL, Material.COBBLESTONE.getKey(), 3); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); + ibc.add(Environment.NORMAL, Material.COBBLESTONE.getKey()); listener.setIsland("test-island-id", ibc); Block block = mockBlock(Material.COBBLESTONE, blockLocation); @@ -1125,7 +1231,7 @@ public void testIslandOffsetIncreasesEffectiveLimit() throws Exception { // --- Batch save tests --- @Test - public void testBatchSaveTriggersAfterThreshold() { + void testBatchSaveTriggersAfterThreshold() { // CHANGE_LIMIT = 9, so the 10th change triggers a save for (int i = 0; i < 10; i++) { Block block = mockBlock(Material.STONE, blockLocation); @@ -1139,7 +1245,7 @@ public void testBatchSaveTriggersAfterThreshold() { } @Test - public void testNoSaveBeforeThresholdReached() { + void testNoSaveBeforeThresholdReached() { // Fire 9 events (CHANGE_LIMIT = 9, save triggers when > 9, i.e. on 10th) for (int i = 0; i < 9; i++) { Block block = mockBlock(Material.STONE, blockLocation); @@ -1155,7 +1261,7 @@ public void testNoSaveBeforeThresholdReached() { // --- IslandDeleteEvent tests --- @Test - public void testIslandDeleteRemovesFromMaps() { + void testIslandDeleteRemovesFromMaps() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); listener.setIsland("test-island-id", ibc); assertNotNull(listener.getIsland("test-island-id")); @@ -1170,7 +1276,7 @@ public void testIslandDeleteRemovesFromMaps() { } @Test - public void testIslandDeleteCallsDatabaseDelete() { + void testIslandDeleteCallsDatabaseDelete() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); listener.setIsland("test-island-id", ibc); @@ -1183,13 +1289,13 @@ public void testIslandDeleteCallsDatabaseDelete() { listener.onIslandDelete(event); - verify(dbMock).deleteID(eq("test-island-id")); + verify(dbMock).deleteID("test-island-id"); } // --- save tests --- @Test - public void testSaveSavesChangedIslands() { + void testSaveSavesChangedIslands() { String islandId = "save-test-island"; IslandBlockCount ibc = new IslandBlockCount(islandId, "BSkyBlock"); ibc.setChanged(true); diff --git a/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java b/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java index 3816624..d548601 100644 --- a/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java +++ b/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java @@ -11,14 +11,18 @@ import static org.mockito.Mockito.when; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; import org.bukkit.Location; import org.bukkit.World; +import org.bukkit.World.Environment; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.configuration.file.FileConfiguration; @@ -31,13 +35,19 @@ import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason; import org.bukkit.event.entity.EntityBreedEvent; +import org.bukkit.event.Event; +import org.bukkit.event.block.Action; import org.bukkit.event.hanging.HangingPlaceEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.entity.Hanging; import org.bukkit.entity.Minecart; import org.bukkit.entity.Painting; import org.bukkit.entity.Villager; import org.bukkit.event.vehicle.VehicleCreateEvent; import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; import org.bukkit.Material; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -65,7 +75,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class EntityLimitListenerTest { +class EntityLimitListenerTest { @Mock private Limits addon; private EntityLimitListener ell; @@ -86,7 +96,7 @@ public class EntityLimitListenerTest { @BeforeEach - public void setUp() throws Exception { + void setUp() throws Exception { MockBukkit.mock(); // Entity @@ -96,7 +106,12 @@ public void setUp() throws Exception { when(island.getUniqueId()).thenReturn(UUID.randomUUID().toString()); when(island.inIslandSpace(any(Location.class))).thenReturn(true); - ibc = new IslandBlockCount("",""); + ibc = new IslandBlockCount("test-island-id","BSkyBlock"); + // Seed initial entity count for ENDERMAN to 4 (to match the 4 in collection) + ibc.incrementEntity(Environment.NORMAL, EntityType.ENDERMAN); + ibc.incrementEntity(Environment.NORMAL, EntityType.ENDERMAN); + ibc.incrementEntity(Environment.NORMAL, EntityType.ENDERMAN); + ibc.incrementEntity(Environment.NORMAL, EntityType.ENDERMAN); when(bll.getIsland(anyString())).thenReturn(ibc); when(addon.getBlockLimitListener()).thenReturn(bll); @@ -110,6 +125,7 @@ public void setUp() throws Exception { // World when(location.getWorld()).thenReturn(world); when(ent.getWorld()).thenReturn(world); + when(world.getEnvironment()).thenReturn(Environment.NORMAL); collection = new ArrayList<>(); collection.add(ent); collection.add(ent); @@ -148,7 +164,7 @@ public void setUp() throws Exception { } @AfterEach - public void tearDown() { + void tearDown() { User.clearUsers(); MockBukkit.unmock(); } @@ -157,7 +173,7 @@ public void tearDown() { * Test for {@link EntityLimitListener#atLimit(Island, Entity)} */ @Test - public void testAtLimitUnderLimit() { + void testAtLimitUnderLimit() { AtLimitResult result = ell.atLimit(island, ent); assertFalse(result.hit()); } @@ -166,8 +182,10 @@ public void testAtLimitUnderLimit() { * Test for {@link EntityLimitListener#atLimit(Island, Entity)} */ @Test - public void testAtLimitAtLimit() { - collection.add(ent); + void testAtLimitAtLimit() { + // The default limit for ENDERMAN is 5 (from config), and we have 4 already + // Adding one more puts us at the limit + ibc.incrementEntity(Environment.NORMAL, EntityType.ENDERMAN); AtLimitResult result = ell.atLimit(island, ent); assertTrue(result.hit()); assertEquals(EntityType.ENDERMAN, result.getTypelimit().getKey()); @@ -179,8 +197,8 @@ public void testAtLimitAtLimit() { * Test for {@link EntityLimitListener#atLimit(Island, Entity)} */ @Test - public void testAtLimitUnderLimitIslandLimit() { - ibc.setEntityLimit(EntityType.ENDERMAN, 6); + void testAtLimitUnderLimitIslandLimit() { + ibc.setEntityLimit(Environment.NORMAL, EntityType.ENDERMAN, 6); AtLimitResult result = ell.atLimit(island, ent); assertFalse(result.hit()); } @@ -189,9 +207,10 @@ public void testAtLimitUnderLimitIslandLimit() { * Test for {@link EntityLimitListener#atLimit(Island, Entity)} */ @Test - public void testAtLimitAtLimitIslandLimitNotAtLimit() { - ibc.setEntityLimit(EntityType.ENDERMAN, 6); - collection.add(ent); + void testAtLimitAtLimitIslandLimitNotAtLimit() { + // Island limit of 6, we have 4 already, so 5th entity should not be at limit + ibc.setEntityLimit(Environment.NORMAL, EntityType.ENDERMAN, 6); + ibc.incrementEntity(Environment.NORMAL, EntityType.ENDERMAN); AtLimitResult result = ell.atLimit(island, ent); assertFalse(result.hit()); } @@ -200,10 +219,11 @@ public void testAtLimitAtLimitIslandLimitNotAtLimit() { * Test for {@link EntityLimitListener#atLimit(Island, Entity)} */ @Test - public void testAtLimitAtLimitIslandLimit() { - ibc.setEntityLimit(EntityType.ENDERMAN, 6); - collection.add(ent); - collection.add(ent); + void testAtLimitAtLimitIslandLimit() { + // Island limit of 6, we have 4, add 2 more to reach exactly 6 + ibc.setEntityLimit(Environment.NORMAL, EntityType.ENDERMAN, 6); + ibc.incrementEntity(Environment.NORMAL, EntityType.ENDERMAN); + ibc.incrementEntity(Environment.NORMAL, EntityType.ENDERMAN); AtLimitResult result = ell.atLimit(island, ent); assertTrue(result.hit()); assertEquals(EntityType.ENDERMAN, result.getTypelimit().getKey()); @@ -214,7 +234,7 @@ public void testAtLimitAtLimitIslandLimit() { // --- CreatureSpawnEvent tests --- @Test - public void testCreatureSpawnShoulderEntityIgnored() { + void testCreatureSpawnShoulderEntityIgnored() { LivingEntity chicken = mockEntity(EntityType.CHICKEN, location); CreatureSpawnEvent event = new CreatureSpawnEvent(chicken, SpawnReason.SHOULDER_ENTITY); @@ -224,7 +244,7 @@ public void testCreatureSpawnShoulderEntityIgnored() { } @Test - public void testCreatureSpawnBreedingNonVillagerIgnored() { + void testCreatureSpawnBreedingNonVillagerIgnored() { LivingEntity cow = mockEntity(EntityType.COW, location); CreatureSpawnEvent event = new CreatureSpawnEvent(cow, SpawnReason.BREEDING); @@ -234,7 +254,7 @@ public void testCreatureSpawnBreedingNonVillagerIgnored() { } @Test - public void testCreatureSpawnOutsideGameModeWorldIgnored() { + void testCreatureSpawnOutsideGameModeWorldIgnored() { when(addon.inGameModeWorld(world)).thenReturn(false); LivingEntity chicken = mockEntity(EntityType.CHICKEN, location); @@ -247,7 +267,7 @@ public void testCreatureSpawnOutsideGameModeWorldIgnored() { } @Test - public void testCreatureSpawnOnSpawnIslandAllowed() { + void testCreatureSpawnOnSpawnIslandAllowed() { when(island.isSpawn()).thenReturn(true); // Put CHICKEN at its limit (10 in config) by adding 10 chickens @@ -266,14 +286,13 @@ public void testCreatureSpawnOnSpawnIslandAllowed() { } @Test - public void testSpawnerSpawnReasonNoPlayerNotification() { - // Put CHICKEN at limit so it gets cancelled - LivingEntity chicken = mockEntity(EntityType.CHICKEN, location); - List chickens = new ArrayList<>(); + void testSpawnerSpawnReasonNoPlayerNotification() { + // Put CHICKEN at limit (10 from config) so it gets cancelled + // Pre-fill with 10 chickens for (int i = 0; i < 10; i++) { - chickens.add(mockEntity(EntityType.CHICKEN, location)); + ibc.incrementEntity(Environment.NORMAL, EntityType.CHICKEN); } - when(world.getNearbyEntities(any())).thenReturn(chickens); + LivingEntity chicken = mockEntity(EntityType.CHICKEN, location); CreatureSpawnEvent event = new CreatureSpawnEvent(chicken, SpawnReason.SPAWNER); @@ -287,7 +306,7 @@ public void testSpawnerSpawnReasonNoPlayerNotification() { // --- HangingPlaceEvent tests --- @Test - public void testHangingPlaceNullPlayerIgnored() { + void testHangingPlaceNullPlayerIgnored() { Hanging hanging = mock(Hanging.class); when(hanging.getLocation()).thenReturn(location); when(hanging.getWorld()).thenReturn(world); @@ -303,7 +322,7 @@ public void testHangingPlaceNullPlayerIgnored() { // --- EntityBreedEvent tests --- @Test - public void testBreedingOpPlayerBypasses() { + void testBreedingOpPlayerBypasses() { // Create an op player as breeder Player opPlayer = mock(Player.class); when(opPlayer.isOp()).thenReturn(true); @@ -328,7 +347,7 @@ public void testBreedingOpPlayerBypasses() { when(mother.getUniqueId()).thenReturn(UUID.randomUUID()); // Set CHICKEN limit to 0 so any entity would be over limit - ibc.setEntityLimit(EntityType.CHICKEN, 0); + ibc.setEntityLimit(Environment.NORMAL, EntityType.CHICKEN, 0); EntityBreedEvent event = new EntityBreedEvent(child, mother, father, opPlayer, null, 0); @@ -342,13 +361,11 @@ public void testBreedingOpPlayerBypasses() { // --- CreatureSpawn at-limit tests --- @Test - public void testCreatureSpawnAtLimitCancels() { + void testCreatureSpawnAtLimitCancels() { // Set island-specific CHICKEN limit to 1, pre-fill with 1 - ibc.setEntityLimit(EntityType.CHICKEN, 1); + ibc.setEntityLimit(Environment.NORMAL, EntityType.CHICKEN, 1); + ibc.incrementEntity(Environment.NORMAL, EntityType.CHICKEN); LivingEntity chicken = mockEntity(EntityType.CHICKEN, location); - List chickens = new ArrayList<>(); - chickens.add(mockEntity(EntityType.CHICKEN, location)); - when(world.getNearbyEntities(any())).thenReturn(chickens); CreatureSpawnEvent event = new CreatureSpawnEvent(chicken, SpawnReason.NATURAL); @@ -358,17 +375,15 @@ public void testCreatureSpawnAtLimitCancels() { } @Test - public void testCreatureSpawnBreedingVillagerProcessed() { + void testCreatureSpawnBreedingVillagerProcessed() { // Set island-specific VILLAGER limit to 1, pre-fill with 1 - ibc.setEntityLimit(EntityType.VILLAGER, 1); - LivingEntity villager = mock(Villager.class); + ibc.setEntityLimit(Environment.NORMAL, EntityType.VILLAGER, 1); + ibc.incrementEntity(Environment.NORMAL, EntityType.VILLAGER); + Villager villager = mock(Villager.class); when(villager.getType()).thenReturn(EntityType.VILLAGER); when(villager.getLocation()).thenReturn(location); when(villager.getWorld()).thenReturn(world); when(villager.getUniqueId()).thenReturn(UUID.randomUUID()); - List villagers = new ArrayList<>(); - villagers.add(villager); - when(world.getNearbyEntities(any())).thenReturn(villagers); CreatureSpawnEvent event = new CreatureSpawnEvent(villager, SpawnReason.BREEDING); @@ -378,7 +393,7 @@ public void testCreatureSpawnBreedingVillagerProcessed() { } @Test - public void testCreatureSpawnDebounceSkipsSecond() throws Exception { + void testCreatureSpawnDebounceSkipsSecond() throws Exception { // Access justSpawned list via reflection Field justSpawnedField = EntityLimitListener.class.getDeclaredField("justSpawned"); justSpawnedField.setAccessible(true); @@ -386,11 +401,9 @@ public void testCreatureSpawnDebounceSkipsSecond() throws Exception { List justSpawned = (List) justSpawnedField.get(ell); // Set CHICKEN at limit - ibc.setEntityLimit(EntityType.CHICKEN, 1); + ibc.setEntityLimit(Environment.NORMAL, EntityType.CHICKEN, 1); + ibc.incrementEntity(Environment.NORMAL, EntityType.CHICKEN); LivingEntity chicken = mockEntity(EntityType.CHICKEN, location); - List chickens = new ArrayList<>(); - chickens.add(mockEntity(EntityType.CHICKEN, location)); - when(world.getNearbyEntities(any())).thenReturn(chickens); // Add entity UUID to justSpawned (debounce) justSpawned.add(chicken.getUniqueId()); @@ -405,19 +418,16 @@ public void testCreatureSpawnDebounceSkipsSecond() throws Exception { // --- VehicleCreateEvent tests --- @Test - public void testVehicleCreateAtLimitCancels() { + void testVehicleCreateAtLimitCancels() { // Set island-specific MINECART limit to 1, pre-fill with 1 - ibc.setEntityLimit(EntityType.MINECART, 1); + ibc.setEntityLimit(Environment.NORMAL, EntityType.MINECART, 1); + ibc.incrementEntity(Environment.NORMAL, EntityType.MINECART); Minecart minecart = mock(Minecart.class); when(minecart.getType()).thenReturn(EntityType.MINECART); when(minecart.getLocation()).thenReturn(location); when(minecart.getWorld()).thenReturn(world); when(minecart.getUniqueId()).thenReturn(UUID.randomUUID()); - List minecarts = new ArrayList<>(); - minecarts.add(minecart); - when(world.getNearbyEntities(any())).thenReturn(minecarts); - VehicleCreateEvent event = new VehicleCreateEvent(minecart); ell.onMinecart(event); @@ -426,13 +436,13 @@ public void testVehicleCreateAtLimitCancels() { } @Test - public void testVehicleCreateDebounceSkipsSecond() throws Exception { + void testVehicleCreateDebounceSkipsSecond() throws Exception { Field justSpawnedField = EntityLimitListener.class.getDeclaredField("justSpawned"); justSpawnedField.setAccessible(true); @SuppressWarnings("unchecked") List justSpawned = (List) justSpawnedField.get(ell); - ibc.setEntityLimit(EntityType.MINECART, 1); + ibc.setEntityLimit(Environment.NORMAL, EntityType.MINECART, 1); Minecart minecart = mock(Minecart.class); when(minecart.getType()).thenReturn(EntityType.MINECART); when(minecart.getLocation()).thenReturn(location); @@ -457,19 +467,16 @@ public void testVehicleCreateDebounceSkipsSecond() throws Exception { // --- HangingPlaceEvent at-limit and bypass tests --- @Test - public void testHangingPlaceAtLimitCancels() { + void testHangingPlaceAtLimitCancels() { // Set island-specific PAINTING limit to 1, pre-fill with 1 - ibc.setEntityLimit(EntityType.PAINTING, 1); + ibc.setEntityLimit(Environment.NORMAL, EntityType.PAINTING, 1); + ibc.incrementEntity(Environment.NORMAL, EntityType.PAINTING); Painting painting = mock(Painting.class); when(painting.getType()).thenReturn(EntityType.PAINTING); when(painting.getLocation()).thenReturn(location); when(painting.getWorld()).thenReturn(world); when(painting.getUniqueId()).thenReturn(UUID.randomUUID()); - List paintings = new ArrayList<>(); - paintings.add(painting); - when(world.getNearbyEntities(any())).thenReturn(paintings); - Player player = mock(Player.class); when(player.isOp()).thenReturn(false); when(player.hasPermission(anyString())).thenReturn(false); @@ -484,9 +491,9 @@ public void testHangingPlaceAtLimitCancels() { } @Test - public void testHangingPlaceOpPlayerBypasses() { + void testHangingPlaceOpPlayerBypasses() { // Set island-specific PAINTING limit to 1, pre-fill with 1 - ibc.setEntityLimit(EntityType.PAINTING, 1); + ibc.setEntityLimit(Environment.NORMAL, EntityType.PAINTING, 1); Painting painting = mock(Painting.class); when(painting.getType()).thenReturn(EntityType.PAINTING); when(painting.getLocation()).thenReturn(location); @@ -512,20 +519,29 @@ public void testHangingPlaceOpPlayerBypasses() { // --- Entity group limit tests --- @Test - public void testEntityGroupLimitBlocksSpawnWhenGroupFull() { + void testEntityGroupLimitBlocksSpawnWhenGroupFull() { // Create a custom group "testanimals" covering CHICKEN and COW with limit 2 EntityGroup testGroup = new EntityGroup("testanimals", Set.of(EntityType.CHICKEN, EntityType.COW), 2, Material.BARRIER); Settings settings = addon.getSettings(); + // Add to entity type → group lookup settings.getGroupLimits().put(EntityType.CHICKEN, new ArrayList<>(List.of(testGroup))); settings.getGroupLimits().put(EntityType.COW, new ArrayList<>(List.of(testGroup))); + // Also add the per-environment limit for the group (via reflection since we can't directly access the map) + try { + java.lang.reflect.Field envGroupLimitsField = Settings.class.getDeclaredField("envGroupLimits"); + envGroupLimitsField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map> envGroupLimits = + (java.util.Map>) envGroupLimitsField.get(settings); + envGroupLimits.computeIfAbsent(Environment.NORMAL, k -> new java.util.HashMap<>()) + .put("testanimals", 2); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } // Pre-fill with 2 entities in the group (1 CHICKEN + 1 COW) - LivingEntity chickenEntity = mockEntity(EntityType.CHICKEN, location); - LivingEntity cowEntity = mockEntity(EntityType.COW, location); - List entities = new ArrayList<>(); - entities.add(chickenEntity); - entities.add(cowEntity); - when(world.getNearbyEntities(any())).thenReturn(entities); + ibc.incrementEntity(Environment.NORMAL, EntityType.CHICKEN); + ibc.incrementEntity(Environment.NORMAL, EntityType.COW); // Try to spawn another CHICKEN LivingEntity newChicken = mockEntity(EntityType.CHICKEN, location); @@ -537,7 +553,7 @@ public void testEntityGroupLimitBlocksSpawnWhenGroupFull() { } @Test - public void testEntityGroupLimitAllowsSpawnWhenGroupUnderLimit() { + void testEntityGroupLimitAllowsSpawnWhenGroupUnderLimit() { // Create a custom group "testanimals" covering CHICKEN and COW with limit 3 EntityGroup testGroup = new EntityGroup("testanimals", Set.of(EntityType.CHICKEN, EntityType.COW), 3, Material.BARRIER); Settings settings = addon.getSettings(); @@ -572,4 +588,173 @@ private LivingEntity mockEntity(EntityType type, Location location) { return entity; } + // --- Golem / snowman block-removal tests (#127) --- + + @Test + void testDetectIronGolemRemovesAllBlocksWhenSpawnedAtBody() throws Exception { + grid = new HashMap<>(); + Block base = setBlock(0, 64, 0, Material.IRON_BLOCK); + Block body = setBlock(0, 65, 0, Material.IRON_BLOCK); + Block head = setBlock(0, 66, 0, Material.CARVED_PUMPKIN); + Block arm1 = setBlock(0, 65, -1, Material.IRON_BLOCK); // NORTH + Block arm2 = setBlock(0, 65, 1, Material.IRON_BLOCK); // SOUTH + + // Vanilla can spawn the golem at the BODY block; the old base-anchored search missed + // everything except the spawn block, leaving the base, arms and pumpkin behind (#127). + invokeDetect("detectIronGolem", spawnAt(body)); + + for (Block b : List.of(base, body, head, arm1, arm2)) { + verify(bll).removeBlock(b); + verify(b).setType(Material.AIR); + } + } + + @Test + void testDetectIronGolemRemovesAllBlocksWhenSpawnedAtBase() throws Exception { + grid = new HashMap<>(); + Block base = setBlock(0, 64, 0, Material.IRON_BLOCK); + Block body = setBlock(0, 65, 0, Material.IRON_BLOCK); + Block head = setBlock(0, 66, 0, Material.CARVED_PUMPKIN); + Block arm1 = setBlock(1, 65, 0, Material.IRON_BLOCK); // EAST + Block arm2 = setBlock(-1, 65, 0, Material.IRON_BLOCK); // WEST + + invokeDetect("detectIronGolem", spawnAt(base)); + + for (Block b : List.of(base, body, head, arm1, arm2)) { + verify(bll).removeBlock(b); + verify(b).setType(Material.AIR); + } + } + + @Test + void testDetectSnowmanRemovesAllBlocks() throws Exception { + grid = new HashMap<>(); + Block base = setBlock(0, 64, 0, Material.SNOW_BLOCK); + Block body = setBlock(0, 65, 0, Material.SNOW_BLOCK); + Block head = setBlock(0, 66, 0, Material.JACK_O_LANTERN); + + invokeDetect("detectSnowman", spawnAt(body)); + + for (Block b : List.of(base, body, head)) { + verify(bll).removeBlock(b); + verify(b).setType(Material.AIR); + } + } + + @Test + void testDetectIronGolemLeavesUnrelatedBlocksAlone() throws Exception { + grid = new HashMap<>(); + // A lone iron block with no pumpkin is not a golem — nothing may be erased. + Block stray = setBlock(0, 65, 0, Material.IRON_BLOCK); + + invokeDetect("detectIronGolem", spawnAt(stray)); + + verify(stray, never()).setType(Material.AIR); + verify(bll, never()).removeBlock(any(Block.class)); + } + + /** Coordinate-keyed mock blocks whose getRelative() walks the shared grid. */ + private Map, Block> grid; + + private Block gridBlock(int x, int y, int z) { + return grid.computeIfAbsent(List.of(x, y, z), k -> { + Block b = mock(Block.class); + when(b.getType()).thenReturn(Material.AIR); + when(b.getRelative(any(BlockFace.class))).thenAnswer(inv -> { + BlockFace f = inv.getArgument(0); + return gridBlock(x + f.getModX(), y + f.getModY(), z + f.getModZ()); + }); + return b; + }); + } + + private Block setBlock(int x, int y, int z, Material type) { + Block b = gridBlock(x, y, z); + when(b.getType()).thenReturn(type); + return b; + } + + private Location spawnAt(Block block) { + Location loc = mock(Location.class); + when(loc.getBlock()).thenReturn(block); + return loc; + } + + private void invokeDetect(String method, Location loc) throws Exception { + Method m = EntityLimitListener.class.getDeclaredMethod(method, Location.class); + m.setAccessible(true); + m.invoke(ell, loc); + } + + // --- Spawn-egg interact guard tests (#134) --- + + @Test + void testSpawnEggOnEntityAtLimitIsCancelled() { + ibc.setEntityLimit(Environment.NORMAL, EntityType.ENDERMAN, 4); // seeded count is 4 -> at limit + Player p = eggPlayer(Material.ENDERMAN_SPAWN_EGG, EquipmentSlot.HAND); + Entity clicked = mock(Entity.class); + when(clicked.getLocation()).thenReturn(location); + PlayerInteractEntityEvent e = new PlayerInteractEntityEvent(p, clicked, EquipmentSlot.HAND); + + ell.onSpawnEggUseOnEntity(e); + + // Cancelled before the egg is consumed, rather than only blocking the later spawn. + assertTrue(e.isCancelled()); + } + + @Test + void testSpawnEggOnEntityUnderLimitNotCancelled() { + ibc.setEntityLimit(Environment.NORMAL, EntityType.ENDERMAN, 10); // count 4 < 10 + Player p = eggPlayer(Material.ENDERMAN_SPAWN_EGG, EquipmentSlot.HAND); + Entity clicked = mock(Entity.class); + when(clicked.getLocation()).thenReturn(location); + PlayerInteractEntityEvent e = new PlayerInteractEntityEvent(p, clicked, EquipmentSlot.HAND); + + ell.onSpawnEggUseOnEntity(e); + + assertFalse(e.isCancelled()); + } + + @Test + void testNonSpawnEggOnEntityIgnored() { + ibc.setEntityLimit(Environment.NORMAL, EntityType.ENDERMAN, 4); + Player p = eggPlayer(Material.STICK, EquipmentSlot.HAND); + Entity clicked = mock(Entity.class); + when(clicked.getLocation()).thenReturn(location); + PlayerInteractEntityEvent e = new PlayerInteractEntityEvent(p, clicked, EquipmentSlot.HAND); + + ell.onSpawnEggUseOnEntity(e); + + assertFalse(e.isCancelled()); + } + + @Test + void testSpawnEggOnBlockAtLimitIsCancelled() { + ibc.setEntityLimit(Environment.NORMAL, EntityType.ENDERMAN, 4); + Player p = mock(Player.class); + when(p.isOp()).thenReturn(false); + when(p.hasPermission(anyString())).thenReturn(false); + when(p.getUniqueId()).thenReturn(UUID.randomUUID()); + Block clicked = mock(Block.class); + when(clicked.getLocation()).thenReturn(location); + PlayerInteractEvent e = new PlayerInteractEvent(p, Action.RIGHT_CLICK_BLOCK, + new ItemStack(Material.ENDERMAN_SPAWN_EGG), clicked, BlockFace.UP); + + ell.onSpawnEggUseOnBlock(e); + + // PlayerInteractEvent.isCancelled() is deprecated; cancelling denies the item-in-hand use + assertEquals(Event.Result.DENY, e.useItemInHand()); + } + + private Player eggPlayer(Material item, EquipmentSlot hand) { + Player p = mock(Player.class); + when(p.isOp()).thenReturn(false); + when(p.hasPermission(anyString())).thenReturn(false); + when(p.getUniqueId()).thenReturn(UUID.randomUUID()); + PlayerInventory inv = mock(PlayerInventory.class); + when(p.getInventory()).thenReturn(inv); + when(inv.getItem(hand)).thenReturn(new ItemStack(item)); + return p; + } + } diff --git a/src/test/java/world/bentobox/limits/listeners/PaperShulkerLimitListenerTest.java b/src/test/java/world/bentobox/limits/listeners/PaperShulkerLimitListenerTest.java index c33c729..ab1c361 100644 --- a/src/test/java/world/bentobox/limits/listeners/PaperShulkerLimitListenerTest.java +++ b/src/test/java/world/bentobox/limits/listeners/PaperShulkerLimitListenerTest.java @@ -1,7 +1,6 @@ package world.bentobox.limits.listeners; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -31,7 +30,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class PaperShulkerLimitListenerTest { +class PaperShulkerLimitListenerTest { @Mock private Limits addon; diff --git a/src/test/java/world/bentobox/limits/objects/EntityLimitsDOTest.java b/src/test/java/world/bentobox/limits/objects/EntityLimitsDOTest.java index 3d4b45b..504b294 100644 --- a/src/test/java/world/bentobox/limits/objects/EntityLimitsDOTest.java +++ b/src/test/java/world/bentobox/limits/objects/EntityLimitsDOTest.java @@ -1,7 +1,6 @@ package world.bentobox.limits.objects; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -12,30 +11,30 @@ import org.junit.jupiter.api.Test; -public class EntityLimitsDOTest { +class EntityLimitsDOTest { @Test - public void testDefaultConstructorCreatesEmptySpawnLoc() { + void testDefaultConstructorCreatesEmptySpawnLoc() { EntityLimitsDO eld = new EntityLimitsDO(); assertNotNull(eld.getSpawnLoc()); assertTrue(eld.getSpawnLoc().isEmpty()); } @Test - public void testConstructorWithIdSetsUniqueId() { + void testConstructorWithIdSetsUniqueId() { EntityLimitsDO eld = new EntityLimitsDO("test-id"); assertEquals("test-id", eld.getUniqueId()); } @Test - public void testGetSetUniqueId() { + void testGetSetUniqueId() { EntityLimitsDO eld = new EntityLimitsDO(); eld.setUniqueId("new-id"); assertEquals("new-id", eld.getUniqueId()); } @Test - public void testGetSetSpawnLoc() { + void testGetSetSpawnLoc() { EntityLimitsDO eld = new EntityLimitsDO("id"); Map spawnLoc = new HashMap<>(); UUID uuid = UUID.randomUUID(); @@ -46,46 +45,47 @@ public void testGetSetSpawnLoc() { } @Test - public void testEqualsSameId() { + void testEqualsSameId() { EntityLimitsDO a = new EntityLimitsDO("same"); EntityLimitsDO b = new EntityLimitsDO("same"); assertEquals(a, b); } @Test - public void testEqualsDifferentId() { + void testEqualsDifferentId() { EntityLimitsDO a = new EntityLimitsDO("one"); EntityLimitsDO b = new EntityLimitsDO("two"); assertNotEquals(a, b); } @Test - public void testEqualsNull() { + void testEqualsNull() { EntityLimitsDO a = new EntityLimitsDO("one"); - assertFalse(a.equals(null)); + // assertNotEquals(a, null) so a.equals(null) is actually exercised + assertNotEquals(a, null); } @Test - public void testEqualsSameInstance() { + void testEqualsSameInstance() { EntityLimitsDO a = new EntityLimitsDO("one"); assertEquals(a, a); } @Test - public void testEqualsDifferentClass() { + void testEqualsDifferentClass() { EntityLimitsDO a = new EntityLimitsDO("one"); assertNotEquals("one", a); } @Test - public void testHashCodeSameId() { + void testHashCodeSameId() { EntityLimitsDO a = new EntityLimitsDO("same"); EntityLimitsDO b = new EntityLimitsDO("same"); assertEquals(a.hashCode(), b.hashCode()); } @Test - public void testHashCodeDifferentId() { + void testHashCodeDifferentId() { EntityLimitsDO a = new EntityLimitsDO("one"); EntityLimitsDO b = new EntityLimitsDO("two"); assertNotEquals(a.hashCode(), b.hashCode()); diff --git a/src/test/java/world/bentobox/limits/objects/IslandBlockCountTest.java b/src/test/java/world/bentobox/limits/objects/IslandBlockCountTest.java index 2047665..252f1b7 100644 --- a/src/test/java/world/bentobox/limits/objects/IslandBlockCountTest.java +++ b/src/test/java/world/bentobox/limits/objects/IslandBlockCountTest.java @@ -6,6 +6,7 @@ import org.bukkit.Material; import org.bukkit.NamespacedKey; +import org.bukkit.World.Environment; import org.bukkit.entity.EntityType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -16,172 +17,172 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; -public class IslandBlockCountTest { +class IslandBlockCountTest { private IslandBlockCount ibc; private NamespacedKey stoneKey; @BeforeEach - public void setUp() { + void setUp() { MockBukkit.mock(); stoneKey = Material.STONE.getKey(); ibc = new IslandBlockCount("island1", "BSkyBlock"); } @AfterEach - public void tearDown() { + void tearDown() { MockBukkit.unmock(); } @Test - public void testConstructorSetsFields() { + void testConstructorSetsFields() { assertEquals("island1", ibc.getUniqueId()); assertEquals("BSkyBlock", ibc.getGameMode()); assertTrue(ibc.isChanged()); } @Test - public void testAddIncrementsCount() { - ibc.add(stoneKey); + void testAddIncrementsCount() { + ibc.add(Environment.NORMAL, stoneKey); assertEquals(1, ibc.getBlockCount(stoneKey)); - ibc.add(stoneKey); + ibc.add(Environment.NORMAL, stoneKey); assertEquals(2, ibc.getBlockCount(stoneKey)); } @Test - public void testRemoveDecrementsAndRemovesAtZero() { - ibc.add(stoneKey); - ibc.add(stoneKey); + void testRemoveDecrementsAndRemovesAtZero() { + ibc.add(Environment.NORMAL, stoneKey); + ibc.add(Environment.NORMAL, stoneKey); assertEquals(2, ibc.getBlockCount(stoneKey)); - ibc.remove(stoneKey); + ibc.remove(Environment.NORMAL, stoneKey); assertEquals(1, ibc.getBlockCount(stoneKey)); - ibc.remove(stoneKey); + ibc.remove(Environment.NORMAL, stoneKey); // At 0 the entry should be removed entirely assertEquals(0, ibc.getBlockCount(stoneKey)); - assertFalse(ibc.getBlockCounts().containsKey(stoneKey)); + assertFalse(ibc.getBlockCounts(Environment.NORMAL).containsKey(stoneKey)); } @Test - public void testGetBlockCountUnknownMaterial() { + void testGetBlockCountUnknownMaterial() { assertEquals(0, ibc.getBlockCount(stoneKey)); } @Test - public void testGetBlockLimitUnknownMaterial() { - assertEquals(-1, ibc.getBlockLimit(stoneKey)); + void testGetBlockLimitUnknownMaterial() { + assertEquals(-1, ibc.getBlockLimit(Environment.NORMAL, stoneKey)); } @Test - public void testSetBlockLimitThenGet() { - ibc.setBlockLimit(stoneKey, 50); - assertEquals(50, ibc.getBlockLimit(stoneKey)); + void testSetBlockLimitThenGet() { + ibc.setBlockLimit(Environment.NORMAL, stoneKey, 50); + assertEquals(50, ibc.getBlockLimit(Environment.NORMAL, stoneKey)); } @Test - public void testIsAtLimitNoLimitSet() { - assertFalse(ibc.isAtLimit(stoneKey)); + void testIsAtLimitNoLimitSet() { + assertFalse(ibc.isAtLimit(Environment.NORMAL, stoneKey)); } @Test - public void testIsAtLimitCountBelowLimit() { - ibc.setBlockLimit(stoneKey, 10); - ibc.add(stoneKey); - assertFalse(ibc.isAtLimit(stoneKey)); + void testIsAtLimitCountBelowLimit() { + ibc.setBlockLimit(Environment.NORMAL, stoneKey, 10); + ibc.add(Environment.NORMAL, stoneKey); + assertFalse(ibc.isAtLimit(Environment.NORMAL, stoneKey)); } @Test - public void testIsAtLimitCountAtLimit() { - ibc.setBlockLimit(stoneKey, 2); - ibc.add(stoneKey); - ibc.add(stoneKey); - assertTrue(ibc.isAtLimit(stoneKey)); + void testIsAtLimitCountAtLimit() { + ibc.setBlockLimit(Environment.NORMAL, stoneKey, 2); + ibc.add(Environment.NORMAL, stoneKey); + ibc.add(Environment.NORMAL, stoneKey); + assertTrue(ibc.isAtLimit(Environment.NORMAL, stoneKey)); } @Test - public void testIsAtLimitWithOffset() { + void testIsAtLimitWithOffset() { // count=10, limit=10, offset=5 → effective limit is 15, so NOT at limit - ibc.setBlockLimit(stoneKey, 10); - ibc.setBlockLimitsOffset(stoneKey, 5); + ibc.setBlockLimit(Environment.NORMAL, stoneKey, 10); + ibc.setBlockLimitsOffset(Environment.NORMAL, stoneKey, 5); for (int i = 0; i < 10; i++) { - ibc.add(stoneKey); + ibc.add(Environment.NORMAL, stoneKey); } - assertFalse(ibc.isAtLimit(stoneKey)); + assertFalse(ibc.isAtLimit(Environment.NORMAL, stoneKey)); } @Test - public void testIsAtLimitOverloadWithOffset() { - ibc.setBlockLimitsOffset(stoneKey, 5); + void testIsAtLimitOverloadWithOffset() { + ibc.setBlockLimitsOffset(Environment.NORMAL, stoneKey, 5); for (int i = 0; i < 10; i++) { - ibc.add(stoneKey); + ibc.add(Environment.NORMAL, stoneKey); } // isAtLimit(material, limit) → count(10) >= limit(10) + offset(5) = 15 → false - assertFalse(ibc.isAtLimit(stoneKey, 10)); + assertFalse(ibc.isAtLimit(Environment.NORMAL, stoneKey, 10)); // count(10) >= limit(5) + offset(5) = 10 → true - assertTrue(ibc.isAtLimit(stoneKey, 5)); + assertTrue(ibc.isAtLimit(Environment.NORMAL, stoneKey, 5)); } @Test - public void testIsBlockLimited() { - assertFalse(ibc.isBlockLimited(stoneKey)); - ibc.setBlockLimit(stoneKey, 10); - assertTrue(ibc.isBlockLimited(stoneKey)); + void testIsBlockLimited() { + assertFalse(ibc.isBlockLimited(Environment.NORMAL, stoneKey)); + ibc.setBlockLimit(Environment.NORMAL, stoneKey, 10); + assertTrue(ibc.isBlockLimited(Environment.NORMAL, stoneKey)); } @Test - public void testEntityLimits() { - assertEquals(-1, ibc.getEntityLimit(EntityType.ZOMBIE)); - ibc.setEntityLimit(EntityType.ZOMBIE, 25); - assertEquals(25, ibc.getEntityLimit(EntityType.ZOMBIE)); - ibc.clearEntityLimits(); - assertEquals(-1, ibc.getEntityLimit(EntityType.ZOMBIE)); + void testEntityLimits() { + assertEquals(-1, ibc.getEntityLimit(Environment.NORMAL, EntityType.ZOMBIE)); + ibc.setEntityLimit(Environment.NORMAL, EntityType.ZOMBIE, 25); + assertEquals(25, ibc.getEntityLimit(Environment.NORMAL, EntityType.ZOMBIE)); + ibc.clearAllEntityLimits(); + assertEquals(-1, ibc.getEntityLimit(Environment.NORMAL, EntityType.ZOMBIE)); } @Test - public void testEntityGroupLimits() { - assertEquals(-1, ibc.getEntityGroupLimit("monsters")); - ibc.setEntityGroupLimit("monsters", 30); - assertEquals(30, ibc.getEntityGroupLimit("monsters")); - ibc.clearEntityGroupLimits(); - assertEquals(-1, ibc.getEntityGroupLimit("monsters")); + void testEntityGroupLimits() { + assertEquals(-1, ibc.getEntityGroupLimit(Environment.NORMAL, "monsters")); + ibc.setEntityGroupLimit(Environment.NORMAL, "monsters", 30); + assertEquals(30, ibc.getEntityGroupLimit(Environment.NORMAL, "monsters")); + ibc.clearAllEntityGroupLimits(); + assertEquals(-1, ibc.getEntityGroupLimit(Environment.NORMAL, "monsters")); } @Test - public void testBlockLimitsOffset() { - assertEquals(0, ibc.getBlockLimitOffset(stoneKey)); - ibc.setBlockLimitsOffset(stoneKey, 7); - assertEquals(7, ibc.getBlockLimitOffset(stoneKey)); + void testBlockLimitsOffset() { + assertEquals(0, ibc.getBlockLimitOffset(Environment.NORMAL, stoneKey)); + ibc.setBlockLimitsOffset(Environment.NORMAL, stoneKey, 7); + assertEquals(7, ibc.getBlockLimitOffset(Environment.NORMAL, stoneKey)); } @Test - public void testEntityLimitsOffset() { - assertEquals(0, ibc.getEntityLimitOffset(EntityType.ZOMBIE)); - ibc.setEntityLimitsOffset(EntityType.ZOMBIE, 3); - assertEquals(3, ibc.getEntityLimitOffset(EntityType.ZOMBIE)); + void testEntityLimitsOffset() { + assertEquals(0, ibc.getEntityLimitOffset(Environment.NORMAL, EntityType.ZOMBIE)); + ibc.setEntityLimitsOffset(Environment.NORMAL, EntityType.ZOMBIE, 3); + assertEquals(3, ibc.getEntityLimitOffset(Environment.NORMAL, EntityType.ZOMBIE)); } @Test - public void testEntityGroupLimitsOffset() { - assertEquals(0, ibc.getEntityGroupLimitOffset("monsters")); - ibc.setEntityGroupLimitsOffset("monsters", 4); - assertEquals(4, ibc.getEntityGroupLimitOffset("monsters")); + void testEntityGroupLimitsOffset() { + assertEquals(0, ibc.getEntityGroupLimitOffset(Environment.NORMAL, "monsters")); + ibc.setEntityGroupLimitsOffset(Environment.NORMAL, "monsters", 4); + assertEquals(4, ibc.getEntityGroupLimitOffset(Environment.NORMAL, "monsters")); } @Test - public void testSetChangedFalse() { + void testSetChangedFalse() { ibc.setChanged(false); assertFalse(ibc.isChanged()); } @Test - public void testIsGameMode() { + void testIsGameMode() { assertTrue(ibc.isGameMode("BSkyBlock")); assertFalse(ibc.isGameMode("AcidIsland")); } @Test - public void testSetAndGetUniqueId() { + void testSetAndGetUniqueId() { ibc.setUniqueId("island2"); assertEquals("island2", ibc.getUniqueId()); } @@ -199,7 +200,7 @@ private static Gson buildBentoboxGson() { } @Test - public void testReadLegacyJsonWithBareMaterialNames() { + void testReadLegacyJsonWithBareMaterialNames() { // This is the on-disk shape produced by versions of Limits where // blockCounts was Map. Pre-1d0a3b7 databases look // exactly like this and must still load. @@ -225,7 +226,87 @@ public void testReadLegacyJsonWithBareMaterialNames() { } @Test - public void testReadJsonWithNamespacedStringKeys() { + void legacyJsonMigratesIntoOverworldEnv() { + // Legacy data with no envBlockCounts must end up scoped to Environment.NORMAL. + String legacy = """ + { + "uniqueId": "isle", + "gameMode": "BSkyBlock", + "blockCounts": { "minecraft:hopper": 7 }, + "blockLimits": { "minecraft:hopper": 20 }, + "blockLimitsOffset": { "minecraft:hopper": 3 }, + "entityLimits": { "CHICKEN": 12 }, + "entityLimitsOffset": { "CHICKEN": 2 }, + "entityGroupLimits": { "Monsters": 50 }, + "entityGroupLimitsOffset": { "Monsters": 10 } + } + """; + IslandBlockCount loaded = buildBentoboxGson().fromJson(legacy, IslandBlockCount.class); + NamespacedKey hopper = NamespacedKey.minecraft("hopper"); + + // Touching any getter triggers migration, after which only the NORMAL env should hold data. + assertEquals(7, loaded.getBlockCount(Environment.NORMAL, hopper)); + assertEquals(0, loaded.getBlockCount(Environment.NETHER, hopper)); + assertEquals(0, loaded.getBlockCount(Environment.THE_END, hopper)); + + assertEquals(20, loaded.getBlockLimit(Environment.NORMAL, hopper)); + assertEquals(-1, loaded.getBlockLimit(Environment.NETHER, hopper)); + + assertEquals(3, loaded.getBlockLimitOffset(Environment.NORMAL, hopper)); + assertEquals(0, loaded.getBlockLimitOffset(Environment.NETHER, hopper)); + + assertEquals(12, loaded.getEntityLimit(Environment.NORMAL, EntityType.CHICKEN)); + assertEquals(-1, loaded.getEntityLimit(Environment.NETHER, EntityType.CHICKEN)); + + assertEquals(2, loaded.getEntityLimitOffset(Environment.NORMAL, EntityType.CHICKEN)); + assertEquals(50, loaded.getEntityGroupLimit(Environment.NORMAL, "Monsters")); + assertEquals(10, loaded.getEntityGroupLimitOffset(Environment.NORMAL, "Monsters")); + } + + @Test + void allEnvsSetterAppliesUniformly() { + ibc.setBlockLimitAllEnvs(stoneKey, 50); + assertEquals(50, ibc.getBlockLimit(Environment.NORMAL, stoneKey)); + assertEquals(50, ibc.getBlockLimit(Environment.NETHER, stoneKey)); + assertEquals(50, ibc.getBlockLimit(Environment.THE_END, stoneKey)); + + ibc.setEntityLimitAllEnvs(EntityType.CHICKEN, 30); + assertEquals(30, ibc.getEntityLimit(Environment.NORMAL, EntityType.CHICKEN)); + assertEquals(30, ibc.getEntityLimit(Environment.NETHER, EntityType.CHICKEN)); + assertEquals(30, ibc.getEntityLimit(Environment.THE_END, EntityType.CHICKEN)); + } + + @Test + void countsAreEnvIndependent() { + ibc.add(Environment.NORMAL, stoneKey); + ibc.add(Environment.NORMAL, stoneKey); + ibc.add(Environment.NETHER, stoneKey); + + assertEquals(2, ibc.getBlockCount(Environment.NORMAL, stoneKey)); + assertEquals(1, ibc.getBlockCount(Environment.NETHER, stoneKey)); + assertEquals(0, ibc.getBlockCount(Environment.THE_END, stoneKey)); + // Sum across envs + assertEquals(3, ibc.getBlockCount(stoneKey)); + } + + @Test + void entityCountIncrementDecrement() { + ibc.incrementEntity(Environment.NORMAL, EntityType.PIG); + ibc.incrementEntity(Environment.NORMAL, EntityType.PIG); + ibc.incrementEntity(Environment.NETHER, EntityType.PIG); + assertEquals(2, ibc.getEntityCount(Environment.NORMAL, EntityType.PIG)); + assertEquals(1, ibc.getEntityCount(Environment.NETHER, EntityType.PIG)); + assertEquals(3, ibc.getEntityCount(EntityType.PIG)); + + ibc.decrementEntity(Environment.NORMAL, EntityType.PIG); + assertEquals(1, ibc.getEntityCount(Environment.NORMAL, EntityType.PIG)); + // Decrementing past zero is a no-op. + ibc.decrementEntity(Environment.THE_END, EntityType.PIG); + assertEquals(0, ibc.getEntityCount(Environment.THE_END, EntityType.PIG)); + } + + @Test + void testReadJsonWithNamespacedStringKeys() { // This is the format the new adapter writes; it must round-trip. String json = """ { @@ -243,18 +324,18 @@ public void testReadJsonWithNamespacedStringKeys() { } @Test - public void testWriteRoundTripWithNamespacedKeys() { - ibc.add(stoneKey); - ibc.add(stoneKey); - ibc.setBlockLimit(NamespacedKey.minecraft("hopper"), 20); - ibc.setBlockLimitsOffset(NamespacedKey.minecraft("hopper"), 5); + void testWriteRoundTripWithNamespacedKeys() { + ibc.add(Environment.NORMAL, stoneKey); + ibc.add(Environment.NORMAL, stoneKey); + ibc.setBlockLimit(Environment.NORMAL, NamespacedKey.minecraft("hopper"), 20); + ibc.setBlockLimitsOffset(Environment.NORMAL, NamespacedKey.minecraft("hopper"), 5); Gson gson = buildBentoboxGson(); String json = gson.toJson(ibc); IslandBlockCount loaded = gson.fromJson(json, IslandBlockCount.class); assertEquals(2, loaded.getBlockCount(stoneKey)); - assertEquals(20, loaded.getBlockLimit(NamespacedKey.minecraft("hopper"))); - assertEquals(5, loaded.getBlockLimitOffset(NamespacedKey.minecraft("hopper"))); + assertEquals(20, loaded.getBlockLimit(Environment.NORMAL, NamespacedKey.minecraft("hopper"))); + assertEquals(5, loaded.getBlockLimitOffset(Environment.NORMAL, NamespacedKey.minecraft("hopper"))); } }