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..8c6bbd8d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +# 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" # blank = skip Hangar + curseforge_id: "1514824" + game_versions: "26.2,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 }} \ No newline at end of file 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..8cd3894f 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -305,7 +305,7 @@ 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); + Integer value = Objects.requireNonNullElse(addon.getBlockConfig().getValue(island.getWorld(), entry.getKey().toLowerCase(java.util.Locale.ENGLISH)), 0); long totalValue = (long) value * entry.getValue(); reportLines.add(" " + Util.prettifyText(entry.getKey()) + " x " + String.format("%,d", entry.getValue()) @@ -730,8 +730,24 @@ 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). + Map donatedBlocksMap = addon.getManager().getDonatedBlocks(island); + long donatedPoints = donatedBlocksMap.entrySet().stream() + .mapToLong(entry -> { + String key = entry.getKey().toLowerCase(java.util.Locale.ENGLISH); + Integer value = addon.getBlockConfig().getValue(island.getWorld(), key); + int count = entry.getValue(); + Integer limit = addon.getBlockConfig().getLimit(key); + if (limit != null) { + count = Math.min(count, limit); + } + return (long) Objects.requireNonNullElse(value, 0) * count; + }) + .sum(); 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..58b88425 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,35 @@ 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 (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 +288,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/panels/DonationPanel.java b/src/main/java/world/bentobox/level/panels/DonationPanel.java index 760611e4..72baef81 100644 --- a/src/main/java/world/bentobox/level/panels/DonationPanel.java +++ b/src/main/java/world/bentobox/level/panels/DonationPanel.java @@ -130,33 +130,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<>(); 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); + } + Map donated = addon.getManager().getDonatedBlocks(island); + for (Map.Entry e : rawTotals.entrySet()) { + String donationId = e.getKey(); + int total = e.getValue(); + Object blockObj = donationId.contains(":") ? donationId : Material.matchMaterial(donationId); + if (blockObj == null) blockObj = donationId; + Integer limit = addon.getBlockConfig().getLimit(blockObj); + 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 +204,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,7 +367,7 @@ private void handleCancel(InventoryClickEvent event, Player player) { private void handleConfirm(InventoryClickEvent event, Player player) { event.setCancelled(true); - if (calculateDonationValue() <= 0) { + if (computeDonationTotals().acceptedPoints <= 0) { user.sendMessage("island.donate.empty"); return; } diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 7c1da5b9..af28d6f7 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -62,6 +62,8 @@ 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-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..85bef5bc 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -63,6 +63,8 @@ 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-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..fbd7f881 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -67,6 +67,8 @@ 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-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..51398b90 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -60,6 +60,8 @@ 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-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..189631d8 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -62,6 +62,8 @@ 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-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..82f5f68d 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -63,6 +63,8 @@ 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-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..2cc89aa3 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -60,6 +60,8 @@ 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-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..c221e1e7 100644 --- a/src/main/resources/locales/ko.yml +++ b/src/main/resources/locales/ko.yml @@ -63,6 +63,8 @@ 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-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..b9f4af5a 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -63,6 +63,8 @@ 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-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..0e64a8ac 100644 --- a/src/main/resources/locales/nl.yml +++ b/src/main/resources/locales/nl.yml @@ -60,6 +60,8 @@ 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-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..327dc155 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -60,6 +60,8 @@ 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-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..d8494922 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -63,6 +63,8 @@ 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-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..27189632 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -63,6 +63,8 @@ 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-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..710fde47 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -67,6 +67,8 @@ 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-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..81e040df 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -60,6 +60,8 @@ island: gui-title: "Пожертвувати блоки" gui-info: "Пожертвуйте блоки своєму острову|Пожертвовано: [points] очок|Увага: пожертвувані предмети|знищуються і не можуть бути повернуті!" preview: "Очок буде додано: [points]|Ці предмети будуть знищені!" + limit-reached: "Ліміт пожертвування для [material] досягнуто." + 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..7a296346 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -66,6 +66,8 @@ 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-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..eeecc961 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -58,6 +58,8 @@ 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-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..9dfcf811 --- /dev/null +++ b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java @@ -0,0 +1,255 @@ +package world.bentobox.level.calculators; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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(); + assertEquals(true, 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..5945420f 100644 --- a/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java +++ b/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java @@ -248,4 +248,86 @@ 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"); + } }