Skip to content

Commit 43f8bb3

Browse files
authored
Merge pull request #2988 from BentoBoxWorld/feature/admin-range-removebonus
feat: add admin range bonus management commands (removebonus + purgebonus)
2 parents 204f64a + 78bbe27 commit 43f8bb3

29 files changed

Lines changed: 1104 additions & 1 deletion

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArt
4646
group = "world.bentobox" // From <groupId>
4747

4848
// Base properties from <properties>
49-
val buildVersion = "3.17.0"
49+
val buildVersion = "3.17.1"
5050
val buildNumberDefault = "-LOCAL" // Local build identifier
5151
val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version
5252

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public void setup() {
2424
new AdminRangeResetCommand(this);
2525
new AdminRangeAddCommand(this);
2626
new AdminRangeRemoveCommand(this);
27+
new AdminRangeRemoveBonusCommand(this);
28+
new AdminRangePurgeBonusCommand(this);
2729
}
2830

2931
@Override
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package world.bentobox.bentobox.api.commands.admin.range;
2+
3+
import java.util.ArrayList;
4+
import java.util.Collection;
5+
import java.util.List;
6+
import java.util.Optional;
7+
8+
import org.bukkit.Bukkit;
9+
import org.eclipse.jdt.annotation.Nullable;
10+
11+
import world.bentobox.bentobox.api.commands.CompositeCommand;
12+
import world.bentobox.bentobox.api.events.island.IslandEvent;
13+
import world.bentobox.bentobox.api.localization.TextVariables;
14+
import world.bentobox.bentobox.api.user.User;
15+
import world.bentobox.bentobox.database.objects.BonusRangeRecord;
16+
import world.bentobox.bentobox.database.objects.Island;
17+
import world.bentobox.bentobox.util.Util;
18+
19+
/**
20+
* Admin command to remove every bonus range carrying a given id from <b>all</b>
21+
* islands in this gamemode's world.
22+
* <p>
23+
* Addons tag the bonus ranges they grant with their own name (the bonus
24+
* {@code uniqueId}); for example the Upgrades addon stores them under the id
25+
* {@code "Upgrades"}. When such an addon is removed, the bonus ranges it added
26+
* 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.
33+
*
34+
* @author tastybento
35+
* @since 3.17.1
36+
*/
37+
public class AdminRangePurgeBonusCommand extends CompositeCommand {
38+
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();
48+
49+
/**
50+
* Admin command to remove a bonus range id from every island.
51+
* @param parent - parent range command
52+
*/
53+
public AdminRangePurgeBonusCommand(CompositeCommand parent) {
54+
super(parent, "purgebonus");
55+
}
56+
57+
@Override
58+
public void setup() {
59+
setPermission("admin.range.purgebonus");
60+
setOnlyPlayer(false);
61+
setParametersHelp("commands.admin.range.purgebonus.parameters");
62+
setDescription("commands.admin.range.purgebonus.description");
63+
}
64+
65+
@Override
66+
public boolean canExecute(User user, String label, List<String> args) {
67+
if (inPurge) {
68+
user.sendMessage("commands.admin.range.purgebonus.in-progress");
69+
return false;
70+
}
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);
75+
return false;
76+
}
77+
return true;
78+
}
79+
80+
@Override
81+
public boolean execute(User user, String label, List<String> args) {
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+
});
118+
return true;
119+
}
120+
121+
/**
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()}.
135+
*
136+
* @param user the admin running the command
137+
* @param id the bonus range uniqueId to purge
138+
* @param ids the unique ids of the islands found during the async scan
139+
*/
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+
}
148+
int oldRange = island.getProtectionRange();
149+
island.clearBonusRange(id);
150+
int newRange = island.getProtectionRange();
151+
if (oldRange != newRange) {
152+
IslandEvent.builder()
153+
.island(island)
154+
.location(island.getCenter())
155+
.reason(IslandEvent.Reason.RANGE_CHANGE)
156+
.involvedPlayer(island.getOwner())
157+
.admin(true)
158+
.protectionRange(newRange, oldRange)
159+
.build();
160+
}
161+
changed++;
162+
}
163+
getPlugin().log("Purged bonus range '" + id + "' from " + changed + " island(s) in "
164+
+ getWorld().getName());
165+
user.sendMessage("commands.admin.range.purgebonus.success", "[id]", id, TextVariables.NUMBER,
166+
String.valueOf(changed));
167+
}
168+
169+
@Override
170+
public Optional<List<String>> tabComplete(User user, String alias, List<String> args) {
171+
if (args.size() <= 1) {
172+
String lastArg = !args.isEmpty() ? args.getLast() : "";
173+
// Suggest the bonus ids that exist on currently-loaded islands in this world
174+
Collection<Island> cached = getIslands().getIslandCache().getCachedIslands();
175+
List<String> ids = cached.stream().filter(i -> getWorld().equals(i.getWorld()))
176+
.flatMap(i -> i.getBonusRanges().stream()).map(BonusRangeRecord::getUniqueId).distinct().sorted()
177+
.toList();
178+
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()));
181+
}
182+
return Optional.empty();
183+
}
184+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package world.bentobox.bentobox.api.commands.admin.range;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import java.util.Objects;
6+
import java.util.Optional;
7+
import java.util.UUID;
8+
9+
import org.eclipse.jdt.annotation.Nullable;
10+
11+
import world.bentobox.bentobox.api.commands.CompositeCommand;
12+
import world.bentobox.bentobox.api.commands.ConfirmableCommand;
13+
import world.bentobox.bentobox.api.events.island.IslandEvent;
14+
import world.bentobox.bentobox.api.localization.TextVariables;
15+
import world.bentobox.bentobox.api.user.User;
16+
import world.bentobox.bentobox.database.objects.BonusRangeRecord;
17+
import world.bentobox.bentobox.database.objects.Island;
18+
import world.bentobox.bentobox.util.Util;
19+
20+
/**
21+
* Admin command to remove bonus ranges from a player's island.
22+
* <p>
23+
* Bonus ranges are static values added to (or subtracted from) the protection
24+
* range of an island. They are stored in the {@link Island} as
25+
* {@link BonusRangeRecord}s and are typically granted by addons. Admins need a
26+
* way to clear them - for example after the addon that granted them has been
27+
* removed.
28+
* <p>
29+
* Usage: {@code /<admin> range removebonus <player> [id]}. With no id, every
30+
* bonus range is removed. With an id, only the bonus ranges sharing that unique
31+
* id are removed. The available ids are offered through tab completion so the
32+
* admin does not have to guess them.
33+
*
34+
* @author tastybento
35+
* @since 3.17.1
36+
*/
37+
public class AdminRangeRemoveBonusCommand extends ConfirmableCommand {
38+
39+
private Island targetIsland;
40+
private @Nullable UUID targetUUID;
41+
private @Nullable String bonusId;
42+
43+
/**
44+
* Admin command to remove bonus ranges from a player's island.
45+
* @param parent - parent range command
46+
*/
47+
public AdminRangeRemoveBonusCommand(CompositeCommand parent) {
48+
super(parent, "removebonus");
49+
}
50+
51+
@Override
52+
public void setup() {
53+
setPermission("admin.range.removebonus");
54+
setOnlyPlayer(false);
55+
setParametersHelp("commands.admin.range.removebonus.parameters");
56+
setDescription("commands.admin.range.removebonus.description");
57+
}
58+
59+
@Override
60+
public boolean canExecute(User user, String label, List<String> args) {
61+
// Expect the player's name and an optional bonus id
62+
if (args.isEmpty() || args.size() > 2) {
63+
showHelp(this, user);
64+
return false;
65+
}
66+
// Get target player
67+
targetUUID = Util.getUUID(args.get(0));
68+
if (targetUUID == null) {
69+
user.sendMessage("general.errors.unknown-player", TextVariables.NAME, args.get(0));
70+
return false;
71+
}
72+
// Target must have an island in this world
73+
targetIsland = getIslands().getIsland(getWorld(), targetUUID);
74+
if (targetIsland == null) {
75+
user.sendMessage("general.errors.player-has-no-island");
76+
return false;
77+
}
78+
// Nothing to do if there are no bonus ranges at all
79+
if (targetIsland.getBonusRanges().isEmpty()) {
80+
user.sendMessage("commands.admin.range.removebonus.no-bonus");
81+
return false;
82+
}
83+
// A specific bonus id was supplied - it must exist on this island
84+
bonusId = args.size() == 2 ? args.get(1) : null;
85+
if (bonusId != null && targetIsland.getBonusRangeRecord(bonusId).isEmpty()) {
86+
user.sendMessage("commands.admin.range.removebonus.unknown-bonus", "[id]", bonusId);
87+
return false;
88+
}
89+
return true;
90+
}
91+
92+
@Override
93+
public boolean execute(User user, String label, List<String> args) {
94+
Objects.requireNonNull(targetIsland);
95+
Objects.requireNonNull(targetUUID);
96+
askConfirmation(user, () -> removeBonusRanges(user, args.get(0)));
97+
return true;
98+
}
99+
100+
/**
101+
* Removes the bonus range(s) from the target island. If {@link #bonusId} is
102+
* set, only that id's bonus ranges are removed, otherwise all of them are.
103+
* Fires a range change event if the effective protection range changed and
104+
* notifies the user. The island is persisted automatically via {@code setChanged()}.
105+
*
106+
* @param user the admin running the command
107+
* @param name the target player's name (for the feedback message)
108+
*/
109+
void removeBonusRanges(User user, String name) {
110+
int oldRange = targetIsland.getProtectionRange();
111+
112+
int removed;
113+
if (bonusId == null) {
114+
// Remove every bonus range
115+
removed = targetIsland.getBonusRanges().stream().mapToInt(BonusRangeRecord::getRange).sum();
116+
targetIsland.clearAllBonusRanges();
117+
user.sendMessage("commands.admin.range.removebonus.success", TextVariables.NUMBER,
118+
String.valueOf(removed), TextVariables.NAME, name);
119+
} else {
120+
// Remove only the bonus ranges for the given id
121+
removed = targetIsland.getBonusRange(bonusId);
122+
targetIsland.clearBonusRange(bonusId);
123+
user.sendMessage("commands.admin.range.removebonus.success-id", "[id]", bonusId,
124+
TextVariables.NUMBER, String.valueOf(removed), TextVariables.NAME, name);
125+
}
126+
127+
// The effective protection range may have changed - notify addons (not cancellable)
128+
int newRange = targetIsland.getProtectionRange();
129+
if (oldRange != newRange) {
130+
IslandEvent.builder()
131+
.island(targetIsland)
132+
.location(targetIsland.getCenter())
133+
.reason(IslandEvent.Reason.RANGE_CHANGE)
134+
.involvedPlayer(targetUUID)
135+
.admin(true)
136+
.protectionRange(newRange, oldRange)
137+
.build();
138+
}
139+
}
140+
141+
@Override
142+
public Optional<List<String>> tabComplete(User user, String alias, List<String> args) {
143+
String lastArg = !args.isEmpty() ? args.getLast() : "";
144+
if (args.size() <= 1) {
145+
// Don't show every player on the server. Require at least the first letter
146+
if (lastArg.isEmpty()) {
147+
return Optional.empty();
148+
}
149+
return Optional.of(Util.tabLimit(new ArrayList<>(Util.getOnlinePlayerList(user)), lastArg));
150+
} else if (args.size() == 2) {
151+
// Offer the bonus ids that exist on the target player's island
152+
UUID uuid = Util.getUUID(args.get(0));
153+
if (uuid != null) {
154+
Island island = getIslands().getIsland(getWorld(), uuid);
155+
if (island != null) {
156+
List<String> ids = island.getBonusRanges().stream().map(BonusRangeRecord::getUniqueId).distinct()
157+
.toList();
158+
return Optional.of(Util.tabLimit(new ArrayList<>(ids), lastArg));
159+
}
160+
}
161+
}
162+
return Optional.empty();
163+
}
164+
}

src/main/resources/locales/cs.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,22 @@ commands:
241241
success: >-
242242
<green>Úspěšně zmenšena chráněná oblast ostrova hráče </green><aqua>[name]</aqua><green>na </green><aqua></aqua>
243243
[total] <gray>(</gray><aqua>-[number]</aqua><gray>)</gray><green>.</green>
244+
removebonus:
245+
parameters: '<hráč> [id]'
246+
description: 'odebere bonusové rozsahy z ostrova (všechny nebo jeden podle id)'
247+
no-bonus: '<red>Tento ostrov nemá žádné bonusové rozsahy k odebrání!</red>'
248+
unknown-bonus: '<red>Na tomto ostrově neexistuje bonusový rozsah s id </red><aqua>[id]</aqua><red>!</red>'
249+
success: '<green>Odebráno </green><aqua>[number]</aqua><green> z bonusového rozsahu ostrova hráče </green><aqua>[name]</aqua><green>.</green>'
250+
success-id: '<green>Odebrán bonusový rozsah </green><aqua>[id]</aqua><green> (</green><aqua>[number]</aqua><green>) z ostrova hráče </green><aqua>[name]</aqua><green>.</green>'
251+
purgebonus:
252+
parameters: '<id>'
253+
description: 'odebere bonusový rozsah s daným id ze všech ostrovů (např. po odebrání addonu, který jej přidal)'
254+
none: '<red>Žádný ostrov nemá bonusový rozsah s id </red><aqua>[id]</aqua><red>!</red>'
255+
warning: '<gold>Tímto odeberete bonusový rozsah </gold><aqua>[id]</aqua><gold> z </gold><aqua>[number]</aqua><gold> ostrovů.</gold>'
256+
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>'
244260
register:
245261
parameters: <Player>
246262
description: registrovat hráče na nevlastněný ostrov, na kterém se nacházíš

0 commit comments

Comments
 (0)