Skip to content

Commit 78bbe27

Browse files
tastybentoclaude
andcommitted
refactor: make range purgebonus async and confirmation-explicit
Reworks AdminRangePurgeBonusCommand so the potentially heavy island scan no longer blocks the main thread on large servers, following the established purge-command pattern. - The scan that finds islands carrying the bonus id now runs off-thread via IslandsManager#getIslandsASync(); results are marshalled back to the main thread to prompt and to apply. - Confirmation is now explicit: `/<admin> range purgebonus <id>` scans and reports the count, then `/<admin> range purgebonus <id> confirm` applies it (reusing the scanned result - no second scan). An inPurge guard rejects overlapping runs. - The mutation and IslandEvent RANGE_CHANGE firing still happen on the main thread, on the live cached island instances (resolved by id), with per-island re-check in case state changed since the scan. Adds locale keys commands.admin.range.purgebonus.{confirm,in-progress, failed} to en-US and all 22 other locales. Rewrites the test suite (12 tests) to cover the sync seams: findIslandIds, applyPurge, the confirm dispatch, canExecute guards and tab completion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 71ef5e2 commit 78bbe27

25 files changed

Lines changed: 213 additions & 56 deletions

File tree

src/main/java/world/bentobox/bentobox/api/commands/admin/range/AdminRangePurgeBonusCommand.java

Lines changed: 86 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
import java.util.ArrayList;
44
import java.util.Collection;
55
import java.util.List;
6-
import java.util.Objects;
76
import java.util.Optional;
87

8+
import org.bukkit.Bukkit;
99
import org.eclipse.jdt.annotation.Nullable;
1010

1111
import world.bentobox.bentobox.api.commands.CompositeCommand;
12-
import world.bentobox.bentobox.api.commands.ConfirmableCommand;
1312
import world.bentobox.bentobox.api.events.island.IslandEvent;
1413
import world.bentobox.bentobox.api.localization.TextVariables;
1514
import world.bentobox.bentobox.api.user.User;
@@ -25,13 +24,27 @@
2524
* {@code uniqueId}); for example the Upgrades addon stores them under the id
2625
* {@code "Upgrades"}. When such an addon is removed, the bonus ranges it added
2726
* remain on every island it ever touched. This command purges them in one go.
27+
* <p>
28+
* Because a server can hold a large number of islands, the scan that finds the
29+
* affected islands runs asynchronously (off the main thread). The admin is then
30+
* shown the count and must re-run the command with {@code confirm} to apply it.
31+
* The actual mutation and event firing happen back on the main thread, on the
32+
* live cached island instances.
2833
*
2934
* @author tastybento
3035
* @since 3.17.1
3136
*/
32-
public class AdminRangePurgeBonusCommand extends ConfirmableCommand {
37+
public class AdminRangePurgeBonusCommand extends CompositeCommand {
3338

34-
private @Nullable String bonusId;
39+
/** True while an async scan is running, to prevent overlapping runs. */
40+
volatile boolean inPurge;
41+
/** True once a scan has found islands and is awaiting a {@code confirm}. */
42+
boolean toBeConfirmed;
43+
/** The bonus id the pending confirmation is for. */
44+
@Nullable
45+
String pendingId;
46+
/** The unique ids of the islands the pending confirmation will purge. */
47+
List<String> pendingIslandIds = List.of();
3548

3649
/**
3750
* Admin command to remove a bonus range id from every island.
@@ -51,43 +64,87 @@ public void setup() {
5164

5265
@Override
5366
public boolean canExecute(User user, String label, List<String> args) {
54-
// A single bonus id is expected
55-
if (args.size() != 1) {
56-
showHelp(this, user);
67+
if (inPurge) {
68+
user.sendMessage("commands.admin.range.purgebonus.in-progress");
5769
return false;
5870
}
59-
bonusId = args.get(0);
60-
// There must be at least one island carrying this bonus id
61-
if (islandsWithBonus(bonusId).isEmpty()) {
62-
user.sendMessage("commands.admin.range.purgebonus.none", "[id]", bonusId);
71+
// Expect "<id>" or "<id> confirm"
72+
if (args.isEmpty() || args.size() > 2
73+
|| (args.size() == 2 && !args.get(1).equalsIgnoreCase("confirm"))) {
74+
showHelp(this, user);
6375
return false;
6476
}
6577
return true;
6678
}
6779

6880
@Override
6981
public boolean execute(User user, String label, List<String> args) {
70-
Objects.requireNonNull(bonusId);
71-
// Warn how many islands will be affected before the admin confirms
72-
int count = islandsWithBonus(bonusId).size();
73-
user.sendMessage("commands.admin.range.purgebonus.warning", "[id]", bonusId, TextVariables.NUMBER,
74-
String.valueOf(count));
75-
final String id = bonusId;
76-
askConfirmation(user, () -> purge(user, id));
82+
String id = args.get(0);
83+
boolean confirm = args.size() == 2 && args.get(1).equalsIgnoreCase("confirm");
84+
// Apply a pending purge if this is the matching confirmation
85+
if (confirm && toBeConfirmed && id.equals(pendingId)) {
86+
List<String> ids = pendingIslandIds;
87+
toBeConfirmed = false;
88+
pendingId = null;
89+
pendingIslandIds = List.of();
90+
applyPurge(user, id, ids);
91+
return true;
92+
}
93+
// Otherwise (re)scan asynchronously and prompt for confirmation
94+
inPurge = true;
95+
getPlugin().getIslands().getIslandsASync().thenAccept(all -> {
96+
List<String> ids = findIslandIds(all, id);
97+
Bukkit.getScheduler().runTask(getPlugin(), () -> {
98+
inPurge = false;
99+
if (ids.isEmpty()) {
100+
user.sendMessage("commands.admin.range.purgebonus.none", "[id]", id);
101+
return;
102+
}
103+
pendingId = id;
104+
pendingIslandIds = ids;
105+
toBeConfirmed = true;
106+
user.sendMessage("commands.admin.range.purgebonus.warning", "[id]", id, TextVariables.NUMBER,
107+
String.valueOf(ids.size()));
108+
user.sendMessage("commands.admin.range.purgebonus.confirm");
109+
});
110+
}).exceptionally(ex -> {
111+
getPlugin().logStacktrace(ex);
112+
Bukkit.getScheduler().runTask(getPlugin(), () -> {
113+
inPurge = false;
114+
user.sendMessage("commands.admin.range.purgebonus.failed");
115+
});
116+
return null;
117+
});
77118
return true;
78119
}
79120

80121
/**
81-
* Removes the bonus range id from every island that carries it, firing a range
82-
* change event per island whose effective protection range changed. Each island
83-
* is persisted automatically via {@code setChanged()}.
122+
* @return the unique ids of the islands in this world that carry a bonus range
123+
* with the given id.
124+
*/
125+
List<String> findIslandIds(Collection<Island> islands, String id) {
126+
return islands.stream().filter(i -> getWorld().equals(i.getWorld()))
127+
.filter(i -> i.getBonusRangeRecord(id).isPresent()).map(Island::getUniqueId).toList();
128+
}
129+
130+
/**
131+
* Removes the bonus range id from every still-matching island, firing a range
132+
* change event per island whose effective protection range changed. Runs on the
133+
* main thread and operates on the live cached island instances, which are
134+
* persisted automatically via {@code setChanged()}.
84135
*
85136
* @param user the admin running the command
86137
* @param id the bonus range uniqueId to purge
138+
* @param ids the unique ids of the islands found during the async scan
87139
*/
88-
void purge(User user, String id) {
89-
int islandsChanged = 0;
90-
for (Island island : islandsWithBonus(id)) {
140+
void applyPurge(User user, String id, List<String> ids) {
141+
int changed = 0;
142+
for (String uid : ids) {
143+
Island island = getIslands().getIslandById(uid).orElse(null);
144+
// Re-check on the live instance in case it changed since the scan
145+
if (island == null || island.getBonusRangeRecord(id).isEmpty()) {
146+
continue;
147+
}
91148
int oldRange = island.getProtectionRange();
92149
island.clearBonusRange(id);
93150
int newRange = island.getProtectionRange();
@@ -101,21 +158,12 @@ void purge(User user, String id) {
101158
.protectionRange(newRange, oldRange)
102159
.build();
103160
}
104-
islandsChanged++;
161+
changed++;
105162
}
106-
getPlugin().log("Purged bonus range '" + id + "' from " + islandsChanged + " island(s) in "
163+
getPlugin().log("Purged bonus range '" + id + "' from " + changed + " island(s) in "
107164
+ getWorld().getName());
108165
user.sendMessage("commands.admin.range.purgebonus.success", "[id]", id, TextVariables.NUMBER,
109-
String.valueOf(islandsChanged));
110-
}
111-
112-
/**
113-
* @return the islands in this world that carry a bonus range with the given id.
114-
* Uses the island cache so the live, canonical instances are mutated.
115-
*/
116-
private List<Island> islandsWithBonus(String id) {
117-
return getIslands().getIslandCache().getIslands(getWorld()).stream()
118-
.filter(i -> i.getBonusRangeRecord(id).isPresent()).toList();
166+
String.valueOf(changed));
119167
}
120168

121169
@Override
@@ -128,6 +176,8 @@ public Optional<List<String>> tabComplete(User user, String alias, List<String>
128176
.flatMap(i -> i.getBonusRanges().stream()).map(BonusRangeRecord::getUniqueId).distinct().sorted()
129177
.toList();
130178
return Optional.of(Util.tabLimit(new ArrayList<>(ids), lastArg));
179+
} else if (args.size() == 2) {
180+
return Optional.of(Util.tabLimit(new ArrayList<>(List.of("confirm")), args.getLast()));
131181
}
132182
return Optional.empty();
133183
}

src/main/resources/locales/cs.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,9 @@ commands:
254254
none: '<red>Žádný ostrov nemá bonusový rozsah s id </red><aqua>[id]</aqua><red>!</red>'
255255
warning: '<gold>Tímto odeberete bonusový rozsah </gold><aqua>[id]</aqua><gold> z </gold><aqua>[number]</aqua><gold> ostrovů.</gold>'
256256
success: '<green>Odebrán bonusový rozsah </green><aqua>[id]</aqua><green> z </green><aqua>[number]</aqua><green> ostrovů.</green>'
257+
confirm: '<gold>Spusťte příkaz znovu s </gold><aqua>confirm</aqua><gold> pro potvrzení.</gold>'
258+
in-progress: '<red>Čištění bonusových rozsahů již probíhá. Počkejte prosím.</red>'
259+
failed: '<red>Skenování ostrovů selhalo. Zkontrolujte konzoli.</red>'
257260
register:
258261
parameters: <Player>
259262
description: registrovat hráče na nevlastněný ostrov, na kterém se nacházíš

src/main/resources/locales/de.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,9 @@ commands:
274274
none: '<red>Keine Insel hat einen Bonusbereich mit der id </red><aqua>[id]</aqua><red>!</red>'
275275
warning: '<gold>Dies entfernt den Bonusbereich </gold><aqua>[id]</aqua><gold> von </gold><aqua>[number]</aqua><gold> Insel(n).</gold>'
276276
success: '<green>Bonusbereich </green><aqua>[id]</aqua><green> von </green><aqua>[number]</aqua><green> Insel(n) entfernt.</green>'
277+
confirm: '<gold>Führe den Befehl erneut mit </gold><aqua>confirm</aqua><gold> aus, um fortzufahren.</gold>'
278+
in-progress: '<red>Eine Bonusbereich-Bereinigung läuft bereits. Bitte warten.</red>'
279+
failed: '<red>Inseln konnten nicht gescannt werden. Prüfe die Konsole.</red>'
277280
register:
278281
parameters: <spieler>
279282
description: Spieler auf freier Insel registrieren, auf der du dich befindest

src/main/resources/locales/en-US.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ commands:
234234
none: '<red>No [prefix_island] has a bonus range with id </red><aqua>[id]</aqua><red>!</red>'
235235
warning: '<gold>This will remove bonus range </gold><aqua>[id]</aqua><gold> from </gold><aqua>[number]</aqua><gold> island(s).</gold>'
236236
success: '<green>Removed bonus range </green><aqua>[id]</aqua><green> from </green><aqua>[number]</aqua><green> island(s).</green>'
237+
confirm: '<gold>Run the command again with </gold><aqua>confirm</aqua><gold> to proceed.</gold>'
238+
in-progress: '<red>A bonus range purge is already running. Please wait.</red>'
239+
failed: '<red>Failed to scan islands. Check the console.</red>'
237240
register:
238241
parameters: <player>
239242
description: register player to unowned [prefix_island] you are on

src/main/resources/locales/es.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,9 @@ commands:
260260
none: '<red>¡Ninguna isla tiene un rango de bonificación con la id </red><aqua>[id]</aqua><red>!</red>'
261261
warning: '<gold>Esto eliminará el rango de bonificación </gold><aqua>[id]</aqua><gold> de </gold><aqua>[number]</aqua><gold> isla(s).</gold>'
262262
success: '<green>Rango de bonificación </green><aqua>[id]</aqua><green> eliminado de </green><aqua>[number]</aqua><green> isla(s).</green>'
263+
confirm: '<gold>Vuelve a ejecutar el comando con </gold><aqua>confirm</aqua><gold> para continuar.</gold>'
264+
in-progress: '<red>Ya hay una purga de rangos de bonificación en curso. Espera.</red>'
265+
failed: '<red>No se pudieron escanear las islas. Revisa la consola.</red>'
263266
register:
264267
parameters: <jugador>
265268
description: registrar jugador en la isla sin dueño en la que estás

src/main/resources/locales/fr.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,9 @@ commands:
278278
none: '<red>Aucune île n''a de rayon bonus avec l''id </red><aqua>[id]</aqua><red>!</red>'
279279
warning: '<gold>Ceci supprimera le rayon bonus </gold><aqua>[id]</aqua><gold> de </gold><aqua>[number]</aqua><gold> île(s).</gold>'
280280
success: '<green>Rayon bonus </green><aqua>[id]</aqua><green> supprimé de </green><aqua>[number]</aqua><green> île(s).</green>'
281+
confirm: '<gold>Relancez la commande avec </gold><aqua>confirm</aqua><gold> pour continuer.</gold>'
282+
in-progress: '<red>Une purge des rayons bonus est déjà en cours. Veuillez patienter.</red>'
283+
failed: '<red>Échec de l''analyse des îles. Consultez la console.</red>'
281284
register:
282285
parameters: <joueur>
283286
description: enregistrer le joueur sur une île sans propriétaire où vous êtes

src/main/resources/locales/hr.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ commands:
262262
none: '<red>Nijedan otok nema bonus raspon s id-om </red><aqua>[id]</aqua><red>!</red>'
263263
warning: '<gold>Ovo će ukloniti bonus raspon </gold><aqua>[id]</aqua><gold> s </gold><aqua>[number]</aqua><gold> otoka.</gold>'
264264
success: '<green>Uklonjen bonus raspon </green><aqua>[id]</aqua><green> s </green><aqua>[number]</aqua><green> otoka.</green>'
265+
confirm: '<gold>Ponovno pokrenite naredbu s </gold><aqua>confirm</aqua><gold> za potvrdu.</gold>'
266+
in-progress: '<red>Čišćenje bonus raspona već je u tijeku. Pričekajte.</red>'
267+
failed: '<red>Skeniranje otoka nije uspjelo. Provjerite konzolu.</red>'
265268
register:
266269
parameters: <igrač>
267270
description: registrirajte igrača na nevlasnički otok na kojem se nalazite

src/main/resources/locales/hu.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,9 @@ commands:
276276
none: '<red>Egyetlen szigetnek sincs </red><aqua>[id]</aqua><red> id-jű bónusz hatótávolsága!</red>'
277277
warning: '<gold>Ez eltávolítja a(z) </gold><aqua>[id]</aqua><gold> bónusz hatótávolságot </gold><aqua>[number]</aqua><gold> szigetről.</gold>'
278278
success: '<green>A(z) </green><aqua>[id]</aqua><green> bónusz hatótávolság eltávolítva </green><aqua>[number]</aqua><green> szigetről.</green>'
279+
confirm: '<gold>Futtasd újra a parancsot a </gold><aqua>confirm</aqua><gold> kapcsolóval a megerősítéshez.</gold>'
280+
in-progress: '<red>Egy bónusz hatótávolság tisztítás már fut. Kérlek várj.</red>'
281+
failed: '<red>A szigetek beolvasása sikertelen. Ellenőrizd a konzolt.</red>'
279282
register:
280283
parameters: <játékos>
281284
description: regisztrálj játékost egy ismeretlen szigetre, amelyen tartózkodsz

src/main/resources/locales/id.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,9 @@ commands:
263263
none: '<red>Tidak ada pulau yang memiliki rentang bonus dengan id </red><aqua>[id]</aqua><red>!</red>'
264264
warning: '<gold>Ini akan menghapus rentang bonus </gold><aqua>[id]</aqua><gold> dari </gold><aqua>[number]</aqua><gold> pulau.</gold>'
265265
success: '<green>Rentang bonus </green><aqua>[id]</aqua><green> dihapus dari </green><aqua>[number]</aqua><green> pulau.</green>'
266+
confirm: '<gold>Jalankan lagi perintah dengan </gold><aqua>confirm</aqua><gold> untuk melanjutkan.</gold>'
267+
in-progress: '<red>Pembersihan rentang bonus sedang berjalan. Mohon tunggu.</red>'
268+
failed: '<red>Gagal memindai pulau. Periksa konsol.</red>'
266269
register:
267270
parameters: <pemain>
268271
description: daftarkan pemain ke pulau tak berpemilik tempat Anda berada

src/main/resources/locales/it.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,9 @@ commands:
269269
none: '<red>Nessuna isola ha un raggio bonus con id </red><aqua>[id]</aqua><red>!</red>'
270270
warning: '<gold>Questo rimuoverà il raggio bonus </gold><aqua>[id]</aqua><gold> da </gold><aqua>[number]</aqua><gold> isola/e.</gold>'
271271
success: '<green>Raggio bonus </green><aqua>[id]</aqua><green> rimosso da </green><aqua>[number]</aqua><green> isola/e.</green>'
272+
confirm: '<gold>Esegui di nuovo il comando con </gold><aqua>confirm</aqua><gold> per procedere.</gold>'
273+
in-progress: '<red>È già in corso una rimozione dei raggi bonus. Attendi.</red>'
274+
failed: '<red>Scansione delle isole non riuscita. Controlla la console.</red>'
272275
register:
273276
parameters: <giocatore>
274277
description: registra il giocatore nell'isola senza proprietario sulla quale sei

0 commit comments

Comments
 (0)