diff --git a/.github/workflows/modrinth-publish.yml b/.github/workflows/modrinth-publish.yml index ad19ea1d..eaca55cd 100644 --- a/.github/workflows/modrinth-publish.yml +++ b/.github/workflows/modrinth-publish.yml @@ -94,6 +94,7 @@ jobs: 1.21.11 26.1 26.1.1 + 26.1.2 # Path to the built JAR — Maven produces Level-{version}.jar in target/ files: target/Level-${{ github.event.release.tag_name || inputs.tag }}.jar diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..c276f0e6 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,31 @@ +# Level — .github/workflows/publish.yml +# Publishes the jar attached to a GitHub release to CurseForge and Hangar via the +# shared BentoBoxWorld/.github reusable workflow. Downloads the release asset instead of +# rebuilding from source. The reusable workflow is pinned to a commit SHA (Sonar +# githubactions:S7637). workflow_dispatch lets you (re)publish a given version. + +name: Publish release to CurseForge and Hangar + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 1.2.3)" + required: true + type: string + +jobs: + publish: + uses: bentoboxworld/.github/.github/workflows/publish-platforms.yml@ca2dcd167e8db4e0f671a976080744dda43801a6 # master + with: + use_release_asset: "true" # publish the jar attached to the release; do not rebuild + hangar_slug: "Level" + curseforge_id: "1514824" + # Keep in sync with the game-versions list in modrinth-publish.yml + game_versions: "26.1.2,26.1.1,26.1,1.21.11,1.21.10,1.21.9,1.21.8,1.21.7,1.21.6,1.21.5" + version: ${{ inputs.version }} # empty on release events -> falls back to the release tag + secrets: + HANGAR_API_KEY: ${{ secrets.HANGAR_API_KEY }} + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} diff --git a/pom.xml b/pom.xml index c18e98ca..ab563007 100644 --- a/pom.xml +++ b/pom.xml @@ -54,7 +54,7 @@ 5.10.2 5.11.0 - v1.21-SNAPSHOT + 4.110.0 1.21.11-R0.1-SNAPSHOT 3.15.1-SNAPSHOT @@ -69,7 +69,7 @@ -LOCAL - 2.27.0 + 2.28.0 BentoBoxWorld_Level bentobox-world https://sonarcloud.io @@ -182,8 +182,8 @@ - com.github.MockBukkit - MockBukkit + org.mockbukkit.mockbukkit + mockbukkit-v1.21 ${mock-bukkit.version} test diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index dc1ddccf..b4aa2f23 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -62,6 +62,7 @@ import world.bentobox.level.Level; import world.bentobox.level.calculators.Results.Result; import world.bentobox.level.config.BlockConfig; +import world.bentobox.level.util.Utils; public class IslandLevelCalculator { private final UUID calcId = UUID.randomUUID(); // ID for hashing @@ -305,10 +306,15 @@ private List getReport() { donatedBlocks.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .forEach(entry -> { - Integer value = addon.getBlockConfig().getBlockValues().getOrDefault(entry.getKey().toLowerCase(java.util.Locale.ENGLISH), 0); - long totalValue = (long) value * entry.getValue(); + String key = entry.getKey().toLowerCase(java.util.Locale.ENGLISH); + Integer value = Objects.requireNonNullElse(addon.getBlockConfig().getValue(island.getWorld(), key), 0); + // Cap at the current blockconfig limit so the lines sum to the donated total + Integer limit = addon.getBlockConfig().getLimit(key); + int counted = limit == null ? entry.getValue() : Math.min(entry.getValue(), limit); + long totalValue = (long) value * counted; reportLines.add(" " + Util.prettifyText(entry.getKey()) + " x " + String.format("%,d", entry.getValue()) + + (counted < entry.getValue() ? " (capped at " + String.format("%,d", counted) + ")" : "") + " = " + String.format("%,d", totalValue) + " points"); }); reportLines.add(LINE_BREAK); @@ -730,8 +736,13 @@ public void tidyUp() { results.rawBlockCount .addAndGet((long) (results.underWaterBlockCount.get() * addon.getSettings().getUnderWaterMultiplier())); - // Add donated block points (permanent contributions that persist across recalculations) - long donatedPoints = addon.getManager().getDonatedPoints(island); + // Add donated block points (permanent contributions that persist across recalculations). + // Recalculate from the donated blocks map using current block config values so the + // level always reflects the current configuration, even if block values changed since donation. + // Also apply the current block limit: if the limit was lowered after donation, only count + // up to the current limit (donated blocks over the limit are silently ignored). + long donatedPoints = Utils.calculateDonatedPoints(addon.getBlockConfig(), island.getWorld(), + addon.getManager().getDonatedBlocks(island)); results.rawBlockCount.addAndGet(donatedPoints); results.donatedPoints.set(donatedPoints); diff --git a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java index 80af368a..ceb682bf 100644 --- a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java @@ -124,15 +124,41 @@ private boolean handleHandDonation(User user, Island island, List args) } } - final int previewAmount = Math.min(requested, hand.getAmount()); - final long previewPoints = (long) previewAmount * blockValue; + int previewAmount = Math.min(requested, hand.getAmount()); final int finalRequested = requested; + // Apply blockconfig limit to the preview so the confirm prompt shows the + // amount that will actually be destroyed, not the raw request. Object displayKey = customId != null ? customId : material; + String donationId = customId != null ? customId : material.name(); + Object blockObj = customId != null ? (Object) customId : material; + Integer limit = addon.getBlockConfig().getLimit(blockObj); + boolean limited = false; + if (limit != null) { + int already = addon.getManager().getDonatedBlocks(island).getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - already); + if (remaining == 0) { + user.sendMessage("island.donate.limit-reached", + MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user)); + return false; + } + if (previewAmount > remaining) { + previewAmount = remaining; + limited = true; + } + } + + long previewPoints = (long) previewAmount * blockValue; String prompt = user.getTranslation("island.donate.hand.confirm-prompt", TextVariables.NUMBER, String.valueOf(previewAmount), MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), POINTS_PLACEHOLDER, Utils.formatNumber(user, previewPoints)); + if (limited) { + // The limit-notice locale uses '|' as a lore line-break for the GUI; + // translate to '\n' here so the chat confirmation prompt wraps cleanly. + prompt = prompt + "\n" + + user.getTranslation("island.donate.limit-notice").replace('|', '\n'); + } askConfirmation(user, prompt, () -> performHandDonation(user, island, material, customId, blockValue, finalRequested)); return true; @@ -151,6 +177,23 @@ private void performHandDonation(User user, Island island, Material material, St return; } int amount = Math.min(requested, currentHand.getAmount()); + + // Apply blockconfig donation limit + String donationId = customId != null ? customId : material.name(); + Object blockObj = customId != null ? (Object) customId : material; + Object displayKey = customId != null ? customId : material; + Integer limit = addon.getBlockConfig().getLimit(blockObj); + if (limit != null) { + int currentDonated = addon.getManager().getDonatedBlocks(island).getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - currentDonated); + if (remaining == 0) { + user.sendMessage("island.donate.limit-reached", + MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user)); + return; + } + amount = Math.min(amount, remaining); + } + long points = (long) amount * blockValue; if (amount >= currentHand.getAmount()) { @@ -159,11 +202,9 @@ private void performHandDonation(User user, Island island, Material material, St currentHand.setAmount(currentHand.getAmount() - amount); } - String donationId = customId != null ? customId : material.name(); addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, amount, points); addon.getManager().recalculateAfterDonation(island); - Object displayKey = customId != null ? customId : material; user.sendMessage("island.donate.hand.success", TextVariables.NUMBER, String.valueOf(amount), MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), @@ -184,7 +225,9 @@ private boolean handleInvDonation(User user, Island island) { return false; } + Map alreadyDonated = addon.getManager().getDonatedBlocks(island); long totalPoints = 0L; + boolean limited = false; StringBuilder prompt = new StringBuilder( user.getTranslation("island.donate.inv.confirm-header")); for (Map.Entry e : totals.entrySet()) { @@ -197,13 +240,40 @@ private boolean handleInvDonation(User user, Island island) { Object displayKey = mat != null ? mat : e.getKey(); Integer rawValue = addon.getBlockConfig().getValue(getWorld(), displayKey); if (rawValue == null) continue; - long points = (long) rawValue * e.getValue(); + + String donationId = mat != null ? mat.name() : e.getKey(); + Object blockObj = mat != null ? mat : e.getKey(); + int amount = e.getValue(); + Integer limit = addon.getBlockConfig().getLimit(blockObj); + if (limit != null) { + int remaining = Math.max(0, limit - alreadyDonated.getOrDefault(donationId, 0)); + int accepted = Math.min(amount, remaining); + if (accepted < amount) { + limited = true; + } + amount = accepted; + } + if (amount <= 0) { + continue; + } + long points = (long) rawValue * amount; totalPoints += points; prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-line", - TextVariables.NUMBER, String.valueOf(e.getValue()), + TextVariables.NUMBER, String.valueOf(amount), MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), POINTS_PLACEHOLDER, Utils.formatNumber(user, points))); } + if (totalPoints == 0 && limited) { + // Everything offered is already at its donation limit — nothing to confirm + user.sendMessage("island.donate.limit-reached-all"); + return false; + } + if (limited) { + // The limit-notice locale uses '|' as a lore line-break for the GUI; + // translate to '\n' here so the chat confirmation prompt wraps cleanly. + prompt.append('\n').append( + user.getTranslation("island.donate.limit-notice").replace('|', '\n')); + } prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-total", POINTS_PLACEHOLDER, Utils.formatNumber(user, totalPoints))); @@ -223,14 +293,35 @@ private void performInvDonation(User user, Island island) { if (value == null) { continue; } - int amount = item.getAmount(); - long points = (long) value * amount; String customId = addon.getCustomBlockId(item); String donationId = customId != null ? customId : item.getType().name(); + Object blockObj = customId != null ? (Object) customId : item.getType(); + + // Apply blockconfig donation limit: only take up to the remaining capacity + int amount = item.getAmount(); + Integer limit = addon.getBlockConfig().getLimit(blockObj); + if (limit != null) { + // getDonatedBlocks returns the live cached map, already updated by earlier donateBlocks calls + int alreadyDonated = addon.getManager().getDonatedBlocks(island).getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - alreadyDonated); + if (remaining == 0) { + // Limit already reached for this material — leave item in inventory + continue; + } + amount = Math.min(amount, remaining); + } + + // Remove accepted amount from inventory slot + if (amount >= item.getAmount()) { + contents[i] = null; + } else { + item.setAmount(item.getAmount() - amount); + } + + long points = (long) value * amount; donated.merge(donationId, amount, Integer::sum); totalPoints += points; addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, amount, points); - contents[i] = null; } pInv.setStorageContents(contents); diff --git a/src/main/java/world/bentobox/level/config/BlockConfig.java b/src/main/java/world/bentobox/level/config/BlockConfig.java index 62350c39..7c191d80 100644 --- a/src/main/java/world/bentobox/level/config/BlockConfig.java +++ b/src/main/java/world/bentobox/level/config/BlockConfig.java @@ -57,7 +57,8 @@ public BlockConfig(Level addon, YamlConfiguration blockValuesConfig, File file) for (String key : limits.getKeys(false)) { // Convert old materials to namespaced keys key = convertKey(limits, key); - blockLimits.put(key, limits.getInt(key)); + // Store lowercased so lookups are case-insensitive, matching blockValues + blockLimits.put(key.toLowerCase(Locale.ENGLISH), limits.getInt(key)); } } // The blocks section can include blocks, spawners, and namespacedIDs @@ -199,7 +200,7 @@ public Integer getLimit(Object obj) { return blockLimits.get(et.name().toLowerCase(Locale.ENGLISH).concat(SPAWNER)); } if (obj instanceof String s) { - return blockLimits.get(s); + return blockLimits.get(s.toLowerCase(Locale.ENGLISH)); } return null; diff --git a/src/main/java/world/bentobox/level/panels/DonationPanel.java b/src/main/java/world/bentobox/level/panels/DonationPanel.java index 760611e4..1ca313ab 100644 --- a/src/main/java/world/bentobox/level/panels/DonationPanel.java +++ b/src/main/java/world/bentobox/level/panels/DonationPanel.java @@ -86,8 +86,10 @@ private DonationPanel(Level addon, World world, User user, Island island) { // Place decorative items from the template (non-button, non-border entries) layout.decorativeItems.forEach(inventory::setItem); - // Info pane - long currentDonated = addon.getManager().getDonatedPoints(island); + // Info pane — show the effective donated points (current values, limits applied), + // the same figure tidyUp() adds to the level, not the stored donation-time total. + long currentDonated = Utils.calculateDonatedPoints(addon.getBlockConfig(), world, + addon.getManager().getDonatedBlocks(island)); ItemStack info = createNamedItem(layout.infoMaterial, user.getTranslation("island.donate.gui-info", POINTS_PLACEHOLDER, Utils.formatNumber(user, currentDonated))); @@ -130,33 +132,69 @@ private void build() { } /** - * Calculate the total point value of items in the donation slots. + * Per-material donation totals with blockconfig limits applied. */ - private long calculateDonationValue() { - long total = 0; + private static final class DonationTotals { + final Map accepted = new HashMap<>(); + long acceptedPoints = 0; + boolean limited = false; + } + + /** + * Walk the donation slots, total each material's items, and cap each total + * by the remaining capacity allowed by the blockconfig limit. The accepted + * counts and resulting point value drive both the preview and the actual + * donation; the {@code limited} flag triggers the limit-notice lore line. + */ + private DonationTotals computeDonationTotals() { + DonationTotals result = new DonationTotals(); + Map rawTotals = new HashMap<>(); + Map values = new HashMap<>(); + Map blockObjs = new HashMap<>(); for (int slot : layout.donationSlots) { ItemStack item = inventory.getItem(slot); - if (item != null && !item.getType().isAir()) { - String customId = addon.getCustomBlockId(item); - Integer value = customId != null - ? addon.getBlockConfig().getValue(world, customId) - : addon.getBlockConfig().getValue(world, item.getType()); - if (value != null && value > 0) { - total += (long) value * item.getAmount(); + if (item == null || item.getType().isAir()) continue; + String customId = addon.getCustomBlockId(item); + String donationId = customId != null ? customId : item.getType().name(); + Object blockObj = customId != null ? customId : item.getType(); + Integer value = addon.getBlockConfig().getValue(world, blockObj); + if (value == null || value <= 0) continue; + rawTotals.merge(donationId, item.getAmount(), Integer::sum); + values.putIfAbsent(donationId, value); + blockObjs.putIfAbsent(donationId, blockObj); + } + Map donated = addon.getManager().getDonatedBlocks(island); + for (Map.Entry e : rawTotals.entrySet()) { + String donationId = e.getKey(); + int total = e.getValue(); + Integer limit = addon.getBlockConfig().getLimit(blockObjs.get(donationId)); + int accept = total; + if (limit != null) { + int already = donated.getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - already); + accept = Math.min(total, remaining); + if (accept < total) { + result.limited = true; } } + result.accepted.put(donationId, accept); + result.acceptedPoints += (long) accept * values.get(donationId); } - return total; + return result; } /** - * Update the preview pane to show current point value. + * Update the preview pane to show the limited point value, with an extra + * lore line when any material exceeds its blockconfig limit. */ private void updatePreview() { - long points = calculateDonationValue(); - ItemStack preview = createNamedItem(layout.previewMaterial, - user.getTranslation("island.donate.preview", - POINTS_PLACEHOLDER, Utils.formatNumber(user, points))); + DonationTotals totals = computeDonationTotals(); + String text = user.getTranslation("island.donate.preview", + POINTS_PLACEHOLDER, Utils.formatNumber(user, totals.acceptedPoints)); + if (totals.limited) { + text = text + "|" + user.getTranslation("island.donate.limit-notice"); + } + ItemStack preview = createNamedItem(layout.previewMaterial, text); inventory.setItem(layout.previewSlot, preview); } @@ -168,52 +206,61 @@ private boolean isDonationSlot(int slot) { } /** - * Process the donation - consume items and record them. Items with no - * configured value are returned to the player rather than consumed. + * Process the donation - consume items up to each material's blockconfig + * limit and record them. Items beyond the limit (and valueless items) are + * returned to the player rather than consumed. */ private void processDonation() { - Map donations = new HashMap<>(); + DonationTotals totals = computeDonationTotals(); + Map remaining = new HashMap<>(totals.accepted); + Map donatedThisCall = new HashMap<>(); long totalPoints = 0; Player player = user.getPlayer(); for (int slot : layout.donationSlots) { ItemStack item = inventory.getItem(slot); - if (item != null && !item.getType().isAir()) { - String customId = addon.getCustomBlockId(item); - String donationId; - Integer value; - if (customId != null) { - value = addon.getBlockConfig().getValue(world, customId); - donationId = customId; - } else { - Material mat = item.getType(); - value = addon.getBlockConfig().getValue(world, mat); - donationId = mat.name(); - } - if (value != null && value > 0) { - int count = item.getAmount(); - long points = (long) value * count; - donations.merge(donationId, count, Integer::sum); - totalPoints += points; - // Record each material type as a separate donation log entry - addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, count, points); - // Clear the slot - items are consumed - inventory.setItem(slot, null); - } else { - // Return valueless items to the player rather than consuming them - inventory.setItem(slot, null); - Map overflow = player.getInventory().addItem(item); - overflow.values().forEach(drop -> player.getWorld().dropItemNaturally(player.getLocation(), drop)); - } + if (item == null || item.getType().isAir()) continue; + String customId = addon.getCustomBlockId(item); + String donationId = customId != null ? customId : item.getType().name(); + Object blockObj = customId != null ? customId : item.getType(); + Integer value = addon.getBlockConfig().getValue(world, blockObj); + + if (value == null || value <= 0) { + // Return valueless items to the player rather than consuming them + inventory.setItem(slot, null); + Map overflow = player.getInventory().addItem(item); + overflow.values().forEach(drop -> player.getWorld().dropItemNaturally(player.getLocation(), drop)); + continue; + } + + int slotCount = item.getAmount(); + int canAccept = remaining.getOrDefault(donationId, 0); + int take = Math.min(slotCount, canAccept); + int leftover = slotCount - take; + + if (take > 0) { + long points = (long) value * take; + totalPoints += points; + donatedThisCall.merge(donationId, take, Integer::sum); + addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, take, points); + remaining.put(donationId, canAccept - take); + } + + inventory.setItem(slot, null); + if (leftover > 0) { + ItemStack ret = item.clone(); + ret.setAmount(leftover); + Map overflow = player.getInventory().addItem(ret); + overflow.values().forEach(drop -> player.getWorld().dropItemNaturally(player.getLocation(), drop)); } } - if (donations.isEmpty()) { + if (donatedThisCall.isEmpty()) { user.sendMessage("island.donate.empty"); } else { user.sendMessage("island.donate.success", POINTS_PLACEHOLDER, Utils.formatNumber(user, totalPoints), - TextVariables.NUMBER, String.valueOf(donations.values().stream().mapToInt(Integer::intValue).sum())); + TextVariables.NUMBER, String.valueOf(donatedThisCall.values().stream().mapToInt(Integer::intValue).sum())); // Queue a full level recalculation so the donation is reflected immediately addon.getManager().recalculateAfterDonation(island); } @@ -322,8 +369,10 @@ private void handleCancel(InventoryClickEvent event, Player player) { private void handleConfirm(InventoryClickEvent event, Player player) { event.setCancelled(true); - if (calculateDonationValue() <= 0) { - user.sendMessage("island.donate.empty"); + DonationTotals totals = computeDonationTotals(); + if (totals.acceptedPoints <= 0) { + // Distinguish "nothing offered" from "everything offered is at its limit" + user.sendMessage(totals.limited ? "island.donate.limit-reached-all" : "island.donate.empty"); return; } confirmed = true; diff --git a/src/main/java/world/bentobox/level/util/Utils.java b/src/main/java/world/bentobox/level/util/Utils.java index e585e2d8..4aee9431 100644 --- a/src/main/java/world/bentobox/level/util/Utils.java +++ b/src/main/java/world/bentobox/level/util/Utils.java @@ -9,9 +9,13 @@ import java.text.NumberFormat; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import org.bukkit.Material; +import org.bukkit.World; import org.bukkit.entity.EntityType; import org.bukkit.inventory.ItemStack; import org.bukkit.permissions.PermissionAttachmentInfo; @@ -25,6 +29,7 @@ import world.bentobox.bentobox.hooks.LangUtilsHook; import world.bentobox.bentobox.hooks.OraxenHook; import world.bentobox.level.Level; +import world.bentobox.level.config.BlockConfig; public class Utils @@ -39,6 +44,33 @@ public static String formatNumber(User user, long value) { return NumberFormat.getInstance(user.getLocale()).format(value); } + /** + * Calculate the effective point value of a donated-blocks map using current block config + * values (world-specific where set) and capping each block type at its current blockconfig + * limit. This is the single source of truth for how donated blocks contribute to the level; + * the level calculation and any display of the donated total must use it so they agree. + * + * @param blockConfig the block config to read values and limits from + * @param world the world whose value overrides apply + * @param donatedBlocks map of donation id (Material name or custom block id) to count + * @return total donated points after value lookup and limit capping + */ + public static long calculateDonatedPoints(BlockConfig blockConfig, World world, + Map donatedBlocks) { + return donatedBlocks.entrySet().stream() + .mapToLong(entry -> { + String key = entry.getKey().toLowerCase(Locale.ENGLISH); + Integer value = blockConfig.getValue(world, key); + int count = entry.getValue(); + Integer limit = blockConfig.getLimit(key); + if (limit != null) { + count = Math.min(count, limit); + } + return (long) Objects.requireNonNullElse(value, 0) * count; + }) + .sum(); + } + /** * This method sends a message to the user with appended "prefix" text before message. * @param user User who receives message. diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 7c1da5b9..a40cada4 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -62,6 +62,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Limit darování pro [material] byl dosažen." + limit-reached-all: "Všechny tyto bloky dosáhly svých limitů darování." + limit-notice: "Některé bloky podléhají limitům|a nebudou darovány" hand: keyword: "ruka" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index 75ebec95..9c2e78e7 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -63,6 +63,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Das Spendenlimit für [material] wurde erreicht." + limit-reached-all: "Alle diese Blöcke haben ihre Spendenlimits erreicht." + limit-notice: "Einige Blöcke unterliegen Limits|und werden nicht gespendet" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index e18ba4a5..8aa262d5 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -67,6 +67,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "The donation limit for [material] has been reached." + limit-reached-all: "All of these blocks have reached their donation limits." + limit-notice: "Some blocks are subject to limits|and will not be donated" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 8571223b..13abea51 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -60,6 +60,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Se ha alcanzado el límite de donación para [material]." + limit-reached-all: "Todos estos bloques han alcanzado sus límites de donación." + limit-notice: "Algunos bloques están sujetos a límites|y no serán donados" hand: keyword: "mano" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index d939b6b2..9fa89d2f 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -62,6 +62,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "La limite de don pour [material] a été atteinte." + limit-reached-all: "Tous ces blocs ont atteint leur limite de don." + limit-notice: "Certains blocs sont soumis à des limites|et ne seront pas donnés" hand: keyword: "main" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index e9113e50..3dd0e296 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -63,6 +63,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "A(z) [material] adományozási limitje elérve." + limit-reached-all: "Ezek a blokkok mind elérték az adományozási limitjüket." + limit-notice: "Néhány blokkra korlátozás vonatkozik|és nem lesznek adományozva" hand: keyword: "kez" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml index ff360f01..d9a1c0f8 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -60,6 +60,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Batas donasi untuk [material] telah tercapai." + limit-reached-all: "Semua blok ini telah mencapai batas donasinya." + limit-notice: "Beberapa blok memiliki batasan|dan tidak akan didonasikan" hand: keyword: "tangan" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml index 073e26c1..fea49830 100644 --- a/src/main/resources/locales/ko.yml +++ b/src/main/resources/locales/ko.yml @@ -63,6 +63,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material]의 기부 한도에 도달했습니다." + limit-reached-all: "이 블록들은 모두 기부 한도에 도달했습니다." + limit-notice: "일부 블록에는 제한이 있으며|기부되지 않습니다" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index a9f6feb0..d7faa938 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -63,6 +63,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material] ziedošanas limits ir sasniegts." + limit-reached-all: "Visi šie bloki ir sasnieguši savus ziedošanas limitus." + limit-notice: "Dažiem blokiem ir ierobežojumi|un tie netiks ziedoti" hand: keyword: "roka" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml index 9ddd410a..775852d7 100644 --- a/src/main/resources/locales/nl.yml +++ b/src/main/resources/locales/nl.yml @@ -60,6 +60,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "De donatielimiet voor [material] is bereikt." + limit-reached-all: "Al deze blokken hebben hun donatielimiet bereikt." + limit-notice: "Sommige blokken zijn onderworpen aan limieten|en worden niet gedoneerd" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 67b69404..f74c89b7 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -60,6 +60,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Limit darowizn dla [material] został osiągnięty." + limit-reached-all: "Wszystkie te bloki osiągnęły swoje limity darowizn." + limit-notice: "Niektóre bloki podlegają limitom|i nie zostaną przekazane" hand: keyword: "reka" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index ffb289c3..9b7ef9af 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -63,6 +63,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "O limite de doação para [material] foi atingido." + limit-reached-all: "Todos estes blocos atingiram os seus limites de doação." + limit-notice: "Alguns blocos estão sujeitos a limites|e não serão doados" hand: keyword: "mao" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index e483b21b..82d86cd3 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -63,6 +63,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Лимит пожертвования для [material] достигнут." + limit-reached-all: "Все эти блоки достигли своих лимитов пожертвования." + limit-notice: "Некоторые блоки подлежат ограничениям|и не будут пожертвованы" hand: keyword: "рука" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml index 2f8ae2bc..0b3cb557 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -67,6 +67,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material] için bağış limitine ulaşıldı." + limit-reached-all: "Bu blokların tümü bağış limitlerine ulaştı." + limit-notice: "Bazı bloklar limitlere tabidir|ve bağışlanmayacaktır" hand: keyword: "el" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index d8286b34..47f89d44 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -60,6 +60,9 @@ island: gui-title: "Пожертвувати блоки" gui-info: "Пожертвуйте блоки своєму острову|Пожертвовано: [points] очок|Увага: пожертвувані предмети|знищуються і не можуть бути повернуті!" preview: "Очок буде додано: [points]|Ці предмети будуть знищені!" + limit-reached: "Ліміт пожертвування для [material] досягнуто." + limit-reached-all: "Усі ці блоки досягли своїх лімітів пожертвування." + limit-notice: "Деякі блоки підлягають обмеженням|і не будуть пожертвувані" hand: keyword: "рука" success: "Пожертвовано [number] x [material] за [points] постійних очок!" diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml index 9c19d5d8..642a1f22 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -66,6 +66,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Đã đạt đến giới hạn quyên góp cho [material]." + limit-reached-all: "Tất cả các khối này đã đạt đến giới hạn quyên góp." + limit-notice: "Một số khối có giới hạn|và sẽ không được quyên góp" hand: keyword: "tay" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index 6dd71c67..6d6cba63 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -58,6 +58,9 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material] 的捐赠限额已达到。" + limit-reached-all: "这些方块均已达到捐赠限额。" + limit-notice: "某些方块受到限制|将不会被捐赠" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java new file mode 100644 index 00000000..02c6e683 --- /dev/null +++ b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java @@ -0,0 +1,256 @@ +package world.bentobox.level.calculators; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.bukkit.Location; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.level.CommonTestSetup; +import world.bentobox.level.LevelsManager; +import world.bentobox.level.config.BlockConfig; +import world.bentobox.level.config.ConfigSettings; + +/** + * Pins down the contract of {@link IslandLevelCalculator#tidyUp()} for the + * "level 0" cases described in PR #434. Each test asserts both + * {@code pointsFromCurrentLevel} ("progress") and the interval + * {@code pointsFromCurrentLevel + pointsToNextLevel} ("levelcost" as the + * player sees it). + *

+ * Drives the actual code path, not a reimplementation, so a failure here + * means the production code disagrees with the asserted contract. + */ +class IslandLevelCalculatorTidyUpTest extends CommonTestSetup { + + @Mock + private ConfigSettings settings; + @Mock + private LevelsManager manager; + @Mock + private BlockConfig blockConfig; + @Mock + private Pipeliner pipeliner; + + private static final long INITIAL_COUNT = 130L; + private static final long LEVEL_COST = 130L; + + @BeforeEach + @Override + protected void setUp() throws Exception { + super.setUp(); + + // Settings — linear formula, level 0 ends at initialCount + level_cost. + when(addon.getSettings()).thenReturn(settings); + when(settings.getLevelCalc()).thenReturn("blocks / level_cost"); + when(settings.getLevelCost()).thenReturn(LEVEL_COST); + when(settings.isZeroNewIslandLevels()).thenReturn(true); + when(settings.isDonationsOnly()).thenReturn(false); + when(settings.getDeathPenalty()).thenReturn(0); + when(settings.getUnderWaterMultiplier()).thenReturn(1.0); + when(settings.isSumTeamDeaths()).thenReturn(false); + when(settings.isNether()).thenReturn(false); + when(settings.isEnd()).thenReturn(false); + + when(addon.getManager()).thenReturn(manager); + when(manager.getDonatedPoints(any(Island.class))).thenReturn(0L); + when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Collections.emptyMap()); + when(manager.getIslandLevel(any(), any())).thenReturn(0L); + when(manager.getInitialCount(any(Island.class))).thenReturn(INITIAL_COUNT); + + when(addon.getBlockConfig()).thenReturn(blockConfig); + when(blockConfig.getValue(any(), any())).thenReturn(0); + when(blockConfig.getLimit(any())).thenReturn(null); + when(blockConfig.getBlockValues()).thenReturn(Collections.emptyMap()); + + when(addon.getPipeliner()).thenReturn(pipeliner); + when(addon.getInitialIslandCount(any(Island.class))).thenReturn(INITIAL_COUNT); + + PlayersManager players = mock(PlayersManager.class); + when(players.getDeaths(any(), any())).thenReturn(0); + when(addon.getPlayers()).thenReturn(players); + + // Island — tiny protection range keeps the chunks-to-scan queue small + // (the constructor walks it, but tidyUp() does not). + when(island.getProtectionRange()).thenReturn(16); + when(island.getMinProtectedX()).thenReturn(0); + when(island.getMaxProtectedX()).thenReturn(16); + when(island.getMinProtectedZ()).thenReturn(0); + when(island.getMaxProtectedZ()).thenReturn(16); + when(island.getWorld()).thenReturn(world); + + Location centre = mock(Location.class); + when(centre.toVector()).thenReturn(new Vector(0, 0, 0)); + when(island.getCenter()).thenReturn(centre); + + // Sea height — not under water, so the multiplier never fires. + lenient().when(iwm.getSeaHeight(any())).thenReturn(0); + } + + private IslandLevelCalculator newCalculator() { + return new IslandLevelCalculator(addon, island, new CompletableFuture<>(), false); + } + + @Test + @DisplayName("At start: progress=0, interval=level_cost") + void atStart() { + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT); // 130 + calc.tidyUp(); + + assertEquals(0L, r.getLevel(), "level"); + assertEquals(0L, r.getPointsFromCurrentLevel(), "progress at start"); + assertEquals(LEVEL_COST, r.getPointsFromCurrentLevel() + r.getPointsToNextLevel(), + "interval at start"); + } + + @Test + @DisplayName("Below start: progress goes negative, interval still equals level_cost (PR #434 claim)") + void belowStart() { + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT - 8); // 122 + calc.tidyUp(); + + assertEquals(0L, r.getLevel(), "level stays at 0 (modifiedPoints<0 truncates)"); + assertEquals(-8L, r.getPointsFromCurrentLevel(), + "progress should be -8 — the player has lost 8 blocks below the starting count"); + assertEquals(LEVEL_COST, r.getPointsFromCurrentLevel() + r.getPointsToNextLevel(), + "interval should remain level_cost when below start"); + } + + @Test + @DisplayName("Above start within level 0: progress=delta, interval=level_cost") + void aboveStartLevel0() { + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT + 70); // 200 + calc.tidyUp(); + + assertEquals(0L, r.getLevel(), "still level 0 (70/130 truncates)"); + assertEquals(70L, r.getPointsFromCurrentLevel(), "progress = blocks - initialCount"); + assertEquals(LEVEL_COST, r.getPointsFromCurrentLevel() + r.getPointsToNextLevel(), + "interval"); + } + + @Test + @DisplayName("Exactly at level 1 boundary: progress=0, interval=level_cost") + void atLevel1Boundary() { + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT + LEVEL_COST); // 260 → level=1 + calc.tidyUp(); + + assertEquals(1L, r.getLevel(), "level=1"); + assertEquals(0L, r.getPointsFromCurrentLevel(), "just crossed: progress=0"); + assertEquals(LEVEL_COST, r.getPointsFromCurrentLevel() + r.getPointsToNextLevel(), + "interval"); + } + + @Test + @DisplayName("Non-linear sqrt formula, below start: progress is negative, interval is the level-0 width") + void belowStart_sqrtFormula() { + // Switch to a non-linear formula. With zeroing on, modifiedPoints = blocks - initialCount, + // and sqrt(negative) = NaN → cast to 0 → level=0. The earlier "Negative values in + // progression while using a non-linear function" fix (c531317) was for exactly this kind + // of formula, so PR #434 should presumably keep producing a sensible -8/ here. + when(settings.getLevelCalc()).thenReturn("sqrt(blocks)"); + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT - 8); // 122 + + calc.tidyUp(); + + assertEquals(0L, r.getLevel(), "level stays at 0 (sqrt(-8) → NaN → 0)"); + assertEquals(-8L, r.getPointsFromCurrentLevel(), + "progress should reflect the 8-block deficit from initialCount"); + // sqrt(blocks-130) first crosses 1 at blocks=131, so the level-0 interval here is 1 block wide. + // The exact interval isn't the point — we just want progress + remaining to be self-consistent + // and the "remaining to next" not negative. + long remaining = r.getPointsToNextLevel(); + assertTrue(remaining > 0, "pointsToNextLevel should be positive, got " + remaining); + } + + @Test + @DisplayName("Donated blocks under limit: full donation count is used") + void donatedBlocksUnderLimit() { + // Player donated 500 iron blocks; limit is 1000; block value is 3. + // Expected donated points = 500 * 3 = 1500. + when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Map.of("IRON_BLOCK", 500)); + when(blockConfig.getValue(any(), any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(1); + return "iron_block".equals(key) ? 3 : 0; + }); + when(blockConfig.getLimit(any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(0); + return "iron_block".equals(key) ? 1000 : null; + }); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT); + calc.tidyUp(); + + assertEquals(1500L, r.getDonatedPoints(), "donated points should equal 500 * 3 = 1500"); + } + + @Test + @DisplayName("Donated blocks exceed limit: only blocks up to the current limit count") + void donatedBlocksExceedLimit() { + // Player donated 1000 iron blocks; limit was later changed to 500; block value is 3. + // Expected donated points = 500 * 3 = 1500 (NOT 1000 * 3 = 3000). + when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Map.of("IRON_BLOCK", 1000)); + when(blockConfig.getValue(any(), any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(1); + return "iron_block".equals(key) ? 3 : 0; + }); + when(blockConfig.getLimit(any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(0); + return "iron_block".equals(key) ? 500 : null; + }); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT); + calc.tidyUp(); + + assertEquals(1500L, r.getDonatedPoints(), + "donated points should be capped at 500 * 3 = 1500, not 1000 * 3 = 3000"); + } + + @Test + @DisplayName("Donated blocks with no limit: full count is used") + void donatedBlocksNoLimit() { + // Player donated 1000 iron blocks; no limit configured; block value is 3. + // Expected donated points = 1000 * 3 = 3000. + when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Map.of("IRON_BLOCK", 1000)); + when(blockConfig.getValue(any(), any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(1); + return "iron_block".equals(key) ? 3 : 0; + }); + when(blockConfig.getLimit(any(String.class))).thenReturn(null); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT); + calc.tidyUp(); + + assertEquals(3000L, r.getDonatedPoints(), + "donated points should equal 1000 * 3 = 3000 when no limit is configured"); + } +} diff --git a/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java b/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java index 40771dea..fabd655e 100644 --- a/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java +++ b/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java @@ -62,6 +62,9 @@ protected void setUp() throws Exception { super.setUp(); when(addon.getManager()).thenReturn(manager); when(addon.getBlockConfig()).thenReturn(blockConfig); + // Mockito returns 0 (not null) for unstubbed Integer-returning methods, which would + // read as "limit of 0" — the real BlockConfig returns null when no limit is configured. + when(blockConfig.getLimit(any())).thenReturn(null); when(user.getUniqueId()).thenReturn(uuid); when(user.isPlayer()).thenReturn(true); @@ -248,4 +251,107 @@ void testExecuteInvShowsConfirmationPrompt() { // No donation yet — only confirmation requested verify(manager, never()).donateBlocks(any(), any(UUID.class), anyString(), anyInt(), anyLong()); } + + @Test + void testExecuteInvPromptShowsLimitNoticeWhenCapped() { + // 441 cobblestone in inventory, blockconfig limit of 100, nothing donated yet + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(441); + + when(inventory.getStorageContents()).thenReturn(new ItemStack[] { cobble }); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Collections.emptyMap()); + + assertTrue(cmd.execute(user, "donate", List.of("inv"))); + + // limit-notice should be requested because 441 > limit of 100 + verify(user).getTranslation("island.donate.limit-notice"); + // confirm-line should be built using the limited amount (100), not the raw 441 + verify(user).getTranslation(eq("island.donate.inv.confirm-line"), + eq("[number]"), eq("100"), + eq("[material]"), anyString(), + eq("[points]"), anyString()); + } + + @Test + void testExecuteHandPromptShowsLimitNoticeWhenCapped() { + // 64 cobblestone in hand, limit 100, already donated 64 -> only 36 can be donated + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(64); + when(inventory.getItemInMainHand()).thenReturn(cobble); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Map.of("COBBLESTONE", 64)); + + assertTrue(cmd.execute(user, "donate", List.of("hand"))); + + // The confirm prompt should be built using the limited amount (36), not the raw 64 + verify(user).getTranslation(eq("island.donate.hand.confirm-prompt"), + eq("[number]"), eq("36"), + eq("[material]"), anyString(), + eq("[points]"), anyString()); + verify(user).getTranslation("island.donate.limit-notice"); + // No donation yet — only confirmation requested + verify(manager, never()).donateBlocks(any(), any(UUID.class), anyString(), anyInt(), anyLong()); + } + + @Test + void testExecuteHandRejectsWhenLimitAlreadyReached() { + // limit 100, already donated 100 -> no prompt, immediate limit-reached message + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(64); + when(inventory.getItemInMainHand()).thenReturn(cobble); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Map.of("COBBLESTONE", 100)); + + assertFalse(cmd.execute(user, "donate", List.of("hand"))); + + verify(user).sendMessage(eq("island.donate.limit-reached"), + eq("[material]"), anyString()); + verify(user, never()).getTranslation(eq("island.donate.hand.confirm-prompt"), + anyString(), anyString(), anyString(), anyString(), anyString(), anyString()); + } + + @Test + void testExecuteInvPromptNoLimitNoticeWhenUnderCap() { + // 50 cobblestone, limit 100, nothing donated yet — no notice + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(50); + + when(inventory.getStorageContents()).thenReturn(new ItemStack[] { cobble }); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Collections.emptyMap()); + + assertTrue(cmd.execute(user, "donate", List.of("inv"))); + + verify(user, never()).getTranslation("island.donate.limit-notice"); + } + + @Test + void testExecuteInvRejectsWhenEverythingAtLimit() { + // 64 cobblestone in inventory, limit 100, already donated 100 -> nothing donatable, + // no zero-point confirmation prompt, immediate limit-reached-all message + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(64); + + when(inventory.getStorageContents()).thenReturn(new ItemStack[] { cobble }); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Map.of("COBBLESTONE", 100)); + + assertFalse(cmd.execute(user, "donate", List.of("inv"))); + + verify(user).sendMessage("island.donate.limit-reached-all"); + verify(user, never()).getTranslation(eq("island.donate.inv.confirm-total"), + anyString(), anyString()); + verify(manager, never()).donateBlocks(any(), any(UUID.class), anyString(), anyInt(), anyLong()); + } } diff --git a/src/test/java/world/bentobox/level/config/BlockConfigLimitsTest.java b/src/test/java/world/bentobox/level/config/BlockConfigLimitsTest.java new file mode 100644 index 00000000..6594874f --- /dev/null +++ b/src/test/java/world/bentobox/level/config/BlockConfigLimitsTest.java @@ -0,0 +1,64 @@ +package world.bentobox.level.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.File; +import java.nio.file.Files; + +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import world.bentobox.level.CommonTestSetup; + +/** + * Verifies that {@link BlockConfig#getLimit(Object)} lookups are + * case-insensitive and agree regardless of how the caller identifies the + * block (Material, exact-case custom ID, or lowercased key) — the level + * calculation lowercases keys while the donation paths pass IDs as-is, so + * both must resolve to the same limit. + */ +class BlockConfigLimitsTest extends CommonTestSetup { + + private BlockConfig blockConfig; + + @BeforeEach + @Override + protected void setUp() throws Exception { + super.setUp(); + YamlConfiguration config = new YamlConfiguration(); + config.set("limits.COBBLESTONE", 100); // old-style material key + config.set("limits.MyCrystal", 5); // mixed-case custom block ID + config.set("limits.oraxen:my_gem", 7); // namespaced custom block ID + File file = Files.createTempFile("blockconfig", ".yml").toFile(); + file.deleteOnExit(); + blockConfig = new BlockConfig(addon, config, file); + } + + @Test + @DisplayName("Material lookup finds a limit declared with an old-style uppercase key") + void materialLookup() { + assertEquals(100, blockConfig.getLimit(Material.COBBLESTONE)); + } + + @Test + @DisplayName("String lookups find limits regardless of case") + void stringLookupCaseInsensitive() { + assertEquals(100, blockConfig.getLimit("cobblestone")); + assertEquals(100, blockConfig.getLimit("COBBLESTONE")); + assertEquals(5, blockConfig.getLimit("MyCrystal")); + assertEquals(5, blockConfig.getLimit("mycrystal")); + assertEquals(7, blockConfig.getLimit("oraxen:my_gem")); + assertEquals(7, blockConfig.getLimit("ORAXEN:MY_GEM")); + } + + @Test + @DisplayName("Unknown keys have no limit") + void unknownKey() { + assertNull(blockConfig.getLimit("diamond_block")); + assertNull(blockConfig.getLimit(Material.DIAMOND_BLOCK)); + } +} diff --git a/src/test/java/world/bentobox/level/util/UtilsCalculateDonatedPointsTest.java b/src/test/java/world/bentobox/level/util/UtilsCalculateDonatedPointsTest.java new file mode 100644 index 00000000..8aaeff6a --- /dev/null +++ b/src/test/java/world/bentobox/level/util/UtilsCalculateDonatedPointsTest.java @@ -0,0 +1,69 @@ +package world.bentobox.level.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import org.bukkit.World; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import world.bentobox.level.config.BlockConfig; + +/** + * Unit tests for {@link Utils#calculateDonatedPoints(BlockConfig, World, Map)} — + * the single source of truth for how donated blocks contribute to the level. + */ +class UtilsCalculateDonatedPointsTest { + + private BlockConfig blockConfig; + private World world; + + @BeforeEach + void setUp() { + blockConfig = mock(BlockConfig.class); + world = mock(World.class); + // Mockito returns 0 (not null) for unstubbed Integer-returning methods, which would + // read as "limit of 0" — the real BlockConfig returns null when no limit is configured. + when(blockConfig.getLimit(any())).thenReturn(null); + } + + @Test + @DisplayName("Uses current block values and lowercases the donation key") + void usesCurrentValues() { + when(blockConfig.getValue(any(), eq("iron_block"))).thenReturn(3); + assertEquals(1500L, Utils.calculateDonatedPoints(blockConfig, world, Map.of("IRON_BLOCK", 500))); + } + + @Test + @DisplayName("Caps each block type at its current limit") + void capsAtLimit() { + when(blockConfig.getValue(any(), eq("iron_block"))).thenReturn(3); + when(blockConfig.getLimit("iron_block")).thenReturn(200); + assertEquals(600L, Utils.calculateDonatedPoints(blockConfig, world, Map.of("IRON_BLOCK", 500))); + } + + @Test + @DisplayName("Blocks with no configured value contribute nothing") + void noValueMeansZero() { + when(blockConfig.getValue(any(), anyString())).thenReturn(null); + assertEquals(0L, Utils.calculateDonatedPoints(blockConfig, world, Map.of("IRON_BLOCK", 500))); + } + + @Test + @DisplayName("Sums across multiple donated block types") + void sumsAcrossTypes() { + when(blockConfig.getValue(any(), eq("iron_block"))).thenReturn(3); + when(blockConfig.getValue(any(), eq("oraxen:my_gem"))).thenReturn(10); + when(blockConfig.getLimit("oraxen:my_gem")).thenReturn(2); + // 100 * 3 + min(5, 2) * 10 = 320 + assertEquals(320L, Utils.calculateDonatedPoints(blockConfig, world, + Map.of("IRON_BLOCK", 100, "oraxen:my_gem", 5))); + } +}