Page 1 collects the parent freehold (auto-detected from the selection), name, price and
+ * duration; page 2 is a plain-English confirmation. Submit calls
+ * {@link RealtyPaperApi#quickCreateSubregion}. Modelled on {@code SearchDialog}.
+ */
+public final class SubregionDialog {
+
+ static final String BYPASS_PERMISSION = "realty.command.subregion.confirm.bypass";
+ private static final Pattern VALID_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9-]+$");
+
+ private static final String INPUT_PARENT = "parent";
+ private static final String INPUT_NAME = "name";
+ private static final String INPUT_PRICE = "price";
+ private static final String INPUT_DURATION_AMOUNT = "duration_amount";
+ private static final String INPUT_DURATION_UNIT = "duration_unit";
+ private static final String INPUT_UNLIMITED_RENEWALS = "unlimited_renewals";
+ private static final String INPUT_MAX_RENEWALS = "max_renewals";
+ private static final String INPUT_HEIGHT = "height";
+ private static final int DEFAULT_HEIGHT = 16;
+ private static final int FORM_INPUT_WIDTH = 200;
+ /** Stored as the lease's max renewals to mean "no cap"; the backend maps any negative to NULL. */
+ private static final int UNLIMITED_RENEWALS = -1;
+ private static final ClickCallback.Options CLICK_OPTIONS =
+ ClickCallback.Options.builder().uses(ClickCallback.UNLIMITED_USES).build();
+
+ /** Lease-duration units offered in the create dialog. */
+ enum DurationUnit {
+ MINUTES(60L, "Minutes"),
+ HOURS(3600L, "Hours"),
+ DAYS(86400L, "Days"),
+ WEEKS(604800L, "Weeks");
+
+ final long seconds;
+ final String label;
+
+ DurationUnit(long seconds, String label) {
+ this.seconds = seconds;
+ this.label = label;
+ }
+ }
+
+ private static final String TAG_INPUT_PREFIX = "tag_";
+
+ private final RealtyPaperApi api;
+ private final ExecutorState executorState;
+ private final Database database;
+ private final SubregionWandManager wandManager;
+ private final AtomicReference settings;
+ private final AtomicReference realtyTags;
+ private final MessageContainer messages;
+ private final ConcurrentHashMap playerStates = new ConcurrentHashMap<>();
+
+ static final class SubregionState {
+ Region selection;
+ World world;
+ UUID worldId;
+ final List parentCandidates = new ArrayList<>();
+ String parentId;
+ String name = "";
+ String price = "100";
+ String durationAmount = "30";
+ String durationUnit = DurationUnit.DAYS.name();
+ boolean unlimitedRenewals = true;
+ String maxRenewals = "3";
+ final List permittedTagIds = new ArrayList<>();
+ final Set selectedTags = new LinkedHashSet<>();
+ }
+
+ public SubregionDialog(@NotNull RealtyPaperApi api,
+ @NotNull ExecutorState executorState,
+ @NotNull Database database,
+ @NotNull SubregionWandManager wandManager,
+ @NotNull AtomicReference settings,
+ @NotNull AtomicReference realtyTags,
+ @NotNull MessageContainer messages) {
+ this.api = api;
+ this.executorState = executorState;
+ this.database = database;
+ this.wandManager = wandManager;
+ this.settings = settings;
+ this.realtyTags = realtyTags;
+ this.messages = messages;
+ }
+
+ /**
+ * Opens the creation dialog for the player, auto-detecting candidate parent freeholds from the
+ * current wand selection.
+ */
+ public void open(@NotNull Player player) {
+ SubregionWandManager.WandSelection wandSelection = wandManager.get(player.getUniqueId());
+ if (wandSelection == null || !wandSelection.isComplete()) {
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_SELECTION_INCOMPLETE));
+ return;
+ }
+ if (!wandSelection.heightSet()) {
+ // No vertical span chosen yet — collect it first, then this dialog reopens.
+ showHeightDialog(player, wandSelection);
+ return;
+ }
+ Region selection = wandSelection.toRegion();
+ World world = wandSelection.world();
+
+ int minVolume = settings.get().subregionMinVolume();
+ if (selection.getVolume() < minVolume) {
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_TOO_SMALL,
+ Placeholder.unparsed("volume", String.valueOf(selection.getVolume())),
+ Placeholder.unparsed("min-volume", String.valueOf(minVolume))));
+ return;
+ }
+
+ RegionManager regionManager = regionManager(world);
+ if (regionManager == null) {
+ player.sendMessage(messages.messageFor(MessageKeys.COMMON_ERROR,
+ Placeholder.unparsed("error", "Region manager unavailable")));
+ return;
+ }
+
+ boolean canBypass = player.hasPermission(BYPASS_PERMISSION);
+ List geometricCandidates = SubregionSelectionValidator.candidateParents(
+ player.getUniqueId(), canBypass, selection, regionManager);
+ if (geometricCandidates.isEmpty()) {
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_NO_PARENT_CANDIDATES));
+ return;
+ }
+
+ UUID worldId = world.getUID();
+ Collection blacklist = settings.get().subregionTagBlacklist();
+
+ // Filter to actual freeholds that aren't tag-blacklisted, without blocking a worker thread.
+ List> checks = new ArrayList<>();
+ for (ProtectedRegion candidate : geometricCandidates) {
+ String id = candidate.getId();
+ CompletableFuture freehold = api.getFreeholdContract(id, worldId);
+ CompletableFuture> tags = blacklist.isEmpty()
+ ? CompletableFuture.completedFuture(List.of())
+ : api.getTagIdsByRegion(id);
+ checks.add(freehold.thenCombine(tags, (contract, tagList) -> {
+ if (contract == null) {
+ return null;
+ }
+ for (String tag : tagList) {
+ if (blacklist.contains(tag)) {
+ return null;
+ }
+ }
+ return id;
+ }));
+ }
+
+ CompletableFuture.allOf(checks.toArray(new CompletableFuture[0]))
+ .thenAcceptAsync(ignored -> {
+ SubregionState state = new SubregionState();
+ state.selection = selection;
+ state.world = world;
+ state.worldId = worldId;
+ for (CompletableFuture check : checks) {
+ String id = check.join();
+ if (id != null) {
+ state.parentCandidates.add(id);
+ }
+ }
+ if (state.parentCandidates.isEmpty()) {
+ player.sendMessage(messages.messageFor(
+ MessageKeys.SUBREGION_NO_PARENT_CANDIDATES));
+ return;
+ }
+ state.parentId = state.parentCandidates.get(0);
+ for (ConfigRegionTag tag : realtyTags.get().values()) {
+ if (tag.permission() == null
+ || player.hasPermission(tag.permission().node())) {
+ state.permittedTagIds.add(tag.tagId());
+ }
+ }
+ playerStates.put(player.getUniqueId(), state);
+ showCreateDialog(player, state);
+ }, executorState.mainThreadExec())
+ .exceptionally(ex -> {
+ Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_CREATE_ERROR,
+ Placeholder.unparsed("error", String.valueOf(cause.getMessage()))));
+ return null;
+ });
+ }
+
+ /**
+ * Opens the height dialog for the player's current footprint selection.
+ */
+ public void openHeight(@NotNull Player player) {
+ SubregionWandManager.WandSelection selection = wandManager.get(player.getUniqueId());
+ if (selection == null || !selection.isComplete()) {
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_SELECTION_INCOMPLETE));
+ return;
+ }
+ showHeightDialog(player, selection);
+ }
+
+ private void showHeightDialog(@NotNull Player player,
+ @NotNull SubregionWandManager.WandSelection selection) {
+ World world = selection.world();
+ // Floor is the lowest corner the player marked; the slider only sets how tall it is.
+ int baseFloor = selection.minPointY();
+ int worldMax = world.getMaxHeight() - 1;
+ int maxHeight = Math.max(1, worldMax - baseFloor + 1);
+
+ int currentHeight = selection.heightSet()
+ ? selection.ceilingY() - selection.floorY() + 1
+ : DEFAULT_HEIGHT;
+ currentHeight = Math.max(1, Math.min(maxHeight, currentHeight));
+
+ List inputs = new ArrayList<>();
+ inputs.add(DialogInput.numberRange(INPUT_HEIGHT, Component.text("Height"),
+ 1f, (float) maxHeight)
+ .width(250)
+ .step(1f)
+ .initial((float) currentHeight)
+ .labelFormat("%s: %s blocks")
+ .build());
+
+ ClickCallback.Options clickOptions = CLICK_OPTIONS;
+
+ DialogActionCallback previewCallback = (response, audience) -> {
+ saveHeight(selection, response, baseFloor, worldMax);
+ // On success the dialog closes and the wand's particle outline previews the full shape.
+ };
+ DialogActionCallback continueCallback = (response, audience) -> {
+ saveHeight(selection, response, baseFloor, worldMax);
+ open(player);
+ };
+ DialogActionCallback clearCallback = (response, audience) -> {
+ wandManager.clear(player.getUniqueId());
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_SELECTION_CLEARED));
+ };
+
+ List actions = new ArrayList<>();
+ actions.add(ActionButton.builder(Component.text("Preview"))
+ .width(150)
+ .action(DialogAction.customClick(previewCallback, clickOptions))
+ .build());
+ actions.add(ActionButton.builder(Component.text("Continue", NamedTextColor.GREEN))
+ .width(150)
+ .action(DialogAction.customClick(continueCallback, clickOptions))
+ .build());
+ actions.add(ActionButton.builder(Component.text("Clear", NamedTextColor.RED))
+ .width(150)
+ .action(DialogAction.customClick(clearCallback, clickOptions))
+ .build());
+
+ Dialog dialog = Dialog.create(factory -> factory.empty()
+ .base(DialogBase.builder(Component.text("Step 1 of 3: Height"))
+ .canCloseWithEscape(true)
+ .afterAction(DialogBase.DialogAfterAction.CLOSE)
+ .body(List.of(DialogBody.plainMessage(Component.text(
+ "Starts at the corners you placed. Drag to set the height."))))
+ .inputs(inputs)
+ .build())
+ .type(DialogType.multiAction(actions,
+ ActionButton.builder(Component.text("Cancel")).width(150).build(),
+ 1)));
+ player.showDialog(dialog);
+ }
+
+ private void saveHeight(@NotNull SubregionWandManager.WandSelection selection,
+ @NotNull DialogResponseView response,
+ int baseFloor, int worldMax) {
+ Float raw = response.getFloat(INPUT_HEIGHT);
+ int height = raw == null ? DEFAULT_HEIGHT : Math.max(1, Math.round(raw));
+ int ceiling = Math.min(worldMax, baseFloor + height - 1);
+ selection.setHeight(baseFloor, ceiling);
+ }
+
+ private void showCreateDialog(@NotNull Player player, @NotNull SubregionState state) {
+ List inputs = new ArrayList<>();
+
+ List parentEntries = new ArrayList<>();
+ for (String id : state.parentCandidates) {
+ parentEntries.add(SingleOptionDialogInput.OptionEntry.create(
+ id, Component.text(id), id.equals(state.parentId)));
+ }
+ inputs.add(DialogInput.singleOption(INPUT_PARENT, Component.text("Region"),
+ parentEntries)
+ .width(FORM_INPUT_WIDTH).labelVisible(true).build());
+
+ inputs.add(DialogInput.text(INPUT_NAME, Component.text("Name"))
+ .width(FORM_INPUT_WIDTH).initial(state.name).maxLength(40).build());
+
+ inputs.add(DialogInput.text(INPUT_PRICE, Component.text("Price"))
+ .width(FORM_INPUT_WIDTH).initial(state.price).maxLength(15).build());
+
+ inputs.add(DialogInput.text(INPUT_DURATION_AMOUNT, Component.text("Lease length"))
+ .width(FORM_INPUT_WIDTH).initial(state.durationAmount).maxLength(9).build());
+
+ List unitEntries = new ArrayList<>();
+ for (DurationUnit unit : DurationUnit.values()) {
+ unitEntries.add(SingleOptionDialogInput.OptionEntry.create(
+ unit.name(), Component.text(unit.label),
+ unit.name().equals(state.durationUnit)));
+ }
+ inputs.add(DialogInput.singleOption(INPUT_DURATION_UNIT, Component.text("Unit"),
+ unitEntries)
+ .width(FORM_INPUT_WIDTH).labelVisible(true).build());
+
+ inputs.add(DialogInput.bool(INPUT_UNLIMITED_RENEWALS, Component.text("Unlimited renewals"))
+ .initial(state.unlimitedRenewals).onTrue("true").onFalse("false").build());
+ inputs.add(DialogInput.text(INPUT_MAX_RENEWALS, Component.text("Max renewals (if limited)"))
+ .width(FORM_INPUT_WIDTH).initial(state.maxRenewals).maxLength(6).build());
+
+ ClickCallback.Options clickOptions = CLICK_OPTIONS;
+
+ DialogActionCallback nextCallback = (response, audience) -> {
+ saveCreate(state, response);
+ RegionManager regionManager = regionManager(state.world);
+ if (regionManager == null || !validate(player, state, regionManager)) {
+ showCreateDialog(player, state);
+ return;
+ }
+ showConfirmDialog(player, state);
+ };
+ DialogActionCallback tagsCallback = (response, audience) -> {
+ saveCreate(state, response);
+ showTagDialog(player, state);
+ };
+
+ List actions = new ArrayList<>();
+ actions.add(ActionButton.builder(Component.text("Next", NamedTextColor.GREEN))
+ .width(150)
+ .action(DialogAction.customClick(nextCallback, clickOptions))
+ .build());
+ if (!state.permittedTagIds.isEmpty()) {
+ actions.add(ActionButton.builder(Component.text("Tags"))
+ .width(150)
+ .action(DialogAction.customClick(tagsCallback, clickOptions))
+ .build());
+ }
+
+ Dialog dialog = Dialog.create(factory -> factory.empty()
+ .base(DialogBase.builder(Component.text("Step 2 of 3: Details"))
+ .canCloseWithEscape(true)
+ .afterAction(DialogBase.DialogAfterAction.CLOSE)
+ .body(List.of(DialogBody.plainMessage(Component.text(
+ "Landlord: " + player.getName()))))
+ .inputs(inputs)
+ .build())
+ .type(DialogType.multiAction(actions,
+ ActionButton.builder(Component.text("Cancel")).width(150).build(),
+ 1)));
+ player.showDialog(dialog);
+ }
+
+ private void showTagDialog(@NotNull Player player, @NotNull SubregionState state) {
+ RealtyTags tags = realtyTags.get();
+ List inputs = new ArrayList<>();
+ for (int i = 0; i < state.permittedTagIds.size(); i++) {
+ String tagId = state.permittedTagIds.get(i);
+ ConfigRegionTag tag = tags.get(tagId);
+ if (tag == null) {
+ continue;
+ }
+ inputs.add(DialogInput.bool(TAG_INPUT_PREFIX + i, tag.tagDisplayName())
+ .initial(state.selectedTags.contains(tagId))
+ .onTrue("true").onFalse("false").build());
+ }
+
+ ClickCallback.Options clickOptions = CLICK_OPTIONS;
+
+ DialogActionCallback doneCallback = (response, audience) -> {
+ saveTags(state, response);
+ showCreateDialog(player, state);
+ };
+
+ List actions = new ArrayList<>();
+ actions.add(ActionButton.builder(Component.text("Done", NamedTextColor.GREEN))
+ .width(150)
+ .action(DialogAction.customClick(doneCallback, clickOptions))
+ .build());
+
+ Dialog dialog = Dialog.create(factory -> factory.empty()
+ .base(DialogBase.builder(Component.text("Step 2 of 3: Tags"))
+ .canCloseWithEscape(true)
+ .afterAction(DialogBase.DialogAfterAction.CLOSE)
+ .body(List.of(DialogBody.plainMessage(Component.text(
+ "Pick the tags for this subregion."))))
+ .inputs(inputs)
+ .build())
+ .type(DialogType.multiAction(actions,
+ ActionButton.builder(Component.text("Cancel")).width(150).build(),
+ 1)));
+ player.showDialog(dialog);
+ }
+
+ private void showConfirmDialog(@NotNull Player player, @NotNull SubregionState state) {
+ double price = parsePrice(state.price);
+
+ String renewals = state.unlimitedRenewals ? "Unlimited" : state.maxRenewals;
+
+ List body = new ArrayList<>();
+ body.add(DialogBody.plainMessage(Component.text()
+ .append(Component.text("Renting out "))
+ .append(Component.text(state.name, NamedTextColor.AQUA))
+ .build()));
+ body.add(DialogBody.plainMessage(Component.text("Landlord: " + player.getName())));
+ body.add(DialogBody.plainMessage(Component.text()
+ .append(Component.text("Price: "))
+ .append(Component.text(CurrencyFormatter.format(price), NamedTextColor.GREEN))
+ .build()));
+ body.add(DialogBody.plainMessage(Component.text()
+ .append(Component.text("Lease: "))
+ .append(Component.text(durationSummary(state), NamedTextColor.GREEN))
+ .build()));
+ body.add(DialogBody.plainMessage(Component.text("Renewals: " + renewals)));
+ if (!state.selectedTags.isEmpty()) {
+ body.add(DialogBody.plainMessage(Component.text(
+ "Tags: " + String.join(", ", state.selectedTags))));
+ }
+
+ ClickCallback.Options clickOptions = CLICK_OPTIONS;
+
+ DialogActionCallback confirmCallback = (response, audience) -> submit(player, state);
+ DialogActionCallback backCallback = (response, audience) -> showCreateDialog(player, state);
+
+ List actions = new ArrayList<>();
+ actions.add(ActionButton.builder(Component.text("Confirm", NamedTextColor.GREEN))
+ .width(150)
+ .action(DialogAction.customClick(confirmCallback, clickOptions))
+ .build());
+ actions.add(ActionButton.builder(Component.text("Back"))
+ .width(150)
+ .action(DialogAction.customClick(backCallback, clickOptions))
+ .build());
+
+ Dialog dialog = Dialog.create(factory -> factory.empty()
+ .base(DialogBase.builder(Component.text("Step 3 of 3: Confirm"))
+ .canCloseWithEscape(true)
+ .afterAction(DialogBase.DialogAfterAction.CLOSE)
+ .body(body)
+ .inputs(List.of())
+ .build())
+ .type(DialogType.multiAction(actions,
+ ActionButton.builder(Component.text("Cancel")).width(150).build(),
+ actions.size())));
+ player.showDialog(dialog);
+ }
+
+ private void submit(@NotNull Player player, @NotNull SubregionState state) {
+ RegionManager regionManager = regionManager(state.world);
+ if (regionManager == null || !validate(player, state, regionManager)) {
+ return;
+ }
+ ProtectedRegion parent = regionManager.getRegion(state.parentId);
+ if (parent == null) {
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_NO_FREEHOLD,
+ Placeholder.unparsed("region", state.parentId)));
+ return;
+ }
+ WorldGuardRegion parentRegion = new WorldGuardRegion(parent, state.world);
+ double price = parsePrice(state.price);
+ long durationSeconds = resolveDuration(state).toSeconds();
+ int maxRenewals = resolveMaxRenewals(state);
+ String name = state.name;
+
+ api.quickCreateSubregion(parentRegion, name, state.selection, price, durationSeconds,
+ maxRenewals, player.getUniqueId())
+ .thenAccept(result -> {
+ switch (result) {
+ case RealtyPaperApi.QuickCreateSubregionResult.Success s -> {
+ wandManager.clear(player.getUniqueId());
+ playerStates.remove(player.getUniqueId());
+ applyTags(s.regionId(), new LinkedHashSet<>(state.selectedTags));
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_CREATE_SUCCESS,
+ Placeholder.unparsed("region", s.regionId()),
+ Placeholder.unparsed("parent", s.parentId())));
+ }
+ case RealtyPaperApi.QuickCreateSubregionResult.NoFreeholdContract nfc ->
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_NO_FREEHOLD,
+ Placeholder.unparsed("region", nfc.parentId())));
+ case RealtyPaperApi.QuickCreateSubregionResult.RegionExists re ->
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_REGION_EXISTS,
+ Placeholder.unparsed("region", re.regionId())));
+ case RealtyPaperApi.QuickCreateSubregionResult.Error error ->
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_CREATE_ERROR,
+ Placeholder.unparsed("error", error.message())));
+ }
+ })
+ .exceptionally(ex -> {
+ Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_CREATE_ERROR,
+ Placeholder.unparsed("error", String.valueOf(cause.getMessage()))));
+ return null;
+ });
+ }
+
+ private void applyTags(@NotNull String regionId, @NotNull Set tagIds) {
+ if (tagIds.isEmpty()) {
+ return;
+ }
+ executorState.dbExec().execute(() -> {
+ try (SqlSessionWrapper session = database.openSession(true)) {
+ RegionTagMapper mapper = session.regionTagMapper();
+ for (String tagId : tagIds) {
+ if (!mapper.exists(tagId, regionId)) {
+ mapper.insert(tagId, regionId);
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Validates the current form, messaging the player and returning {@code false} on the first
+ * problem. Geometry/ownership were already enforced at {@link #open}; this re-checks the
+ * user-entered fields plus name uniqueness and sibling overlap against the chosen parent.
+ */
+ private boolean validate(@NotNull Player player, @NotNull SubregionState state,
+ @NotNull RegionManager regionManager) {
+ if (state.name == null || !VALID_NAME_PATTERN.matcher(state.name).matches()) {
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_INVALID_NAME,
+ Placeholder.unparsed("region", String.valueOf(state.name))));
+ return false;
+ }
+ if (regionManager.getRegion(state.name) != null) {
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_REGION_EXISTS,
+ Placeholder.unparsed("region", state.name)));
+ return false;
+ }
+ ProtectedRegion parent = regionManager.getRegion(state.parentId);
+ if (parent == null) {
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_NO_FREEHOLD,
+ Placeholder.unparsed("region", String.valueOf(state.parentId))));
+ return false;
+ }
+ ProtectedRegion sibling = SubregionSelectionValidator.overlappingSibling(
+ state.selection, parent, regionManager);
+ if (sibling != null) {
+ player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_OVERLAPS_SIBLING,
+ Placeholder.unparsed("sibling", sibling.getId())));
+ return false;
+ }
+ if (parsePrice(state.price) <= 0) {
+ player.sendMessage(messages.messageFor(MessageKeys.COMMON_ERROR,
+ Placeholder.unparsed("error", "Price must be more than 0.")));
+ return false;
+ }
+ Duration duration = resolveDuration(state);
+ if (duration == null || duration.isZero() || duration.isNegative()) {
+ player.sendMessage(messages.messageFor(MessageKeys.COMMON_ERROR,
+ Placeholder.unparsed("error", "Lease length must be more than 0.")));
+ return false;
+ }
+ if (resolveMaxRenewals(state) == null) {
+ player.sendMessage(messages.messageFor(MessageKeys.COMMON_ERROR,
+ Placeholder.unparsed("error", "Max renewals must be 0 or more.")));
+ return false;
+ }
+ return true;
+ }
+
+ private void saveCreate(@NotNull SubregionState state, @NotNull DialogResponseView response) {
+ String parent = response.getText(INPUT_PARENT);
+ if (parent != null) {
+ state.parentId = parent;
+ }
+ String name = response.getText(INPUT_NAME);
+ if (name != null) {
+ state.name = name.trim();
+ }
+ String price = response.getText(INPUT_PRICE);
+ if (price != null) {
+ state.price = price;
+ }
+ String amount = response.getText(INPUT_DURATION_AMOUNT);
+ if (amount != null) {
+ state.durationAmount = amount.trim();
+ }
+ String unit = response.getText(INPUT_DURATION_UNIT);
+ if (unit != null) {
+ state.durationUnit = unit;
+ }
+ Boolean unlimited = response.getBoolean(INPUT_UNLIMITED_RENEWALS);
+ if (unlimited != null) {
+ state.unlimitedRenewals = unlimited;
+ }
+ String renewals = response.getText(INPUT_MAX_RENEWALS);
+ if (renewals != null) {
+ state.maxRenewals = renewals.trim();
+ }
+ }
+
+ private void saveTags(@NotNull SubregionState state, @NotNull DialogResponseView response) {
+ for (int i = 0; i < state.permittedTagIds.size(); i++) {
+ Boolean on = response.getBoolean(TAG_INPUT_PREFIX + i);
+ if (on == null) {
+ continue;
+ }
+ String tagId = state.permittedTagIds.get(i);
+ if (on) {
+ state.selectedTags.add(tagId);
+ } else {
+ state.selectedTags.remove(tagId);
+ }
+ }
+ }
+
+ /**
+ * Returns the max-renewals value to store: {@code -1} for unlimited, otherwise the entered
+ * count. Returns {@code null} if the entered count isn't a valid non-negative number.
+ */
+ private Integer resolveMaxRenewals(@NotNull SubregionState state) {
+ if (state.unlimitedRenewals) {
+ return UNLIMITED_RENEWALS;
+ }
+ try {
+ int value = Integer.parseInt(state.maxRenewals.trim());
+ return value < 0 ? null : value;
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ private Duration resolveDuration(@NotNull SubregionState state) {
+ long amount;
+ try {
+ amount = Long.parseLong(state.durationAmount.trim());
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ if (amount <= 0) {
+ return null;
+ }
+ return Duration.ofSeconds(amount * durationUnitOf(state).seconds);
+ }
+
+ private static DurationUnit durationUnitOf(@NotNull SubregionState state) {
+ try {
+ return DurationUnit.valueOf(state.durationUnit);
+ } catch (IllegalArgumentException ex) {
+ return DurationUnit.DAYS;
+ }
+ }
+
+ private static String durationSummary(@NotNull SubregionState state) {
+ return state.durationAmount + " "
+ + durationUnitOf(state).label.toLowerCase(java.util.Locale.ROOT);
+ }
+
+ private static double parsePrice(String text) {
+ if (text == null || text.isBlank()) {
+ return -1;
+ }
+ try {
+ return Double.parseDouble(text.trim());
+ } catch (NumberFormatException ex) {
+ return -1;
+ }
+ }
+
+ private static RegionManager regionManager(@NotNull World world) {
+ return WorldGuard.getInstance().getPlatform().getRegionContainer()
+ .get(BukkitAdapter.adapt(world));
+ }
+}
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/SubregionSelectionValidator.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/SubregionSelectionValidator.java
new file mode 100644
index 00000000..0d268284
--- /dev/null
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/util/SubregionSelectionValidator.java
@@ -0,0 +1,165 @@
+package io.github.md5sha256.realty.command.util;
+
+import com.sk89q.worldedit.math.BlockVector2;
+import com.sk89q.worldedit.math.BlockVector3;
+import com.sk89q.worldedit.regions.CuboidRegion;
+import com.sk89q.worldedit.regions.Polygonal2DRegion;
+import com.sk89q.worldedit.regions.Region;
+import com.sk89q.worldguard.protection.managers.RegionManager;
+import com.sk89q.worldguard.protection.regions.ProtectedRegion;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * Geometry and containment checks shared by the subregion creation flow.
+ *
+ *
Extracted from {@code SubregionCommandGroup} so the wand + dialog flow can reuse the same
+ * containment, overlap and parent-candidate logic that the old typed command used.
+ */
+public final class SubregionSelectionValidator {
+
+ private SubregionSelectionValidator() {
+ }
+
+ /**
+ * Returns the parent regions the player may subregion under: regions that fully contain the
+ * selection and (unless {@code canBypass}) are owned by the player. Freehold-contract and
+ * tag-blacklist filtering happen later against the database — this method is pure WorldGuard
+ * geometry and ownership.
+ */
+ public static @NotNull List candidateParents(@NotNull UUID playerId,
+ boolean canBypass,
+ @NotNull Region selection,
+ @NotNull RegionManager regionManager) {
+ List candidates = new ArrayList<>();
+ for (ProtectedRegion region : regionManager.getRegions().values()) {
+ if (!canBypass && !region.getOwners().contains(playerId)) {
+ continue;
+ }
+ if (!regionIsFullyContainedByParent(selection, region)) {
+ continue;
+ }
+ candidates.add(region);
+ }
+ return candidates;
+ }
+
+ /**
+ * Returns the first sibling subregion (a child of {@code parent}) that the selection overlaps,
+ * or {@code null} if the selection is clear.
+ */
+ public static @Nullable ProtectedRegion overlappingSibling(@NotNull Region selection,
+ @NotNull ProtectedRegion parent,
+ @NotNull RegionManager regionManager) {
+ for (ProtectedRegion sibling : regionManager.getRegions().values()) {
+ if (!Objects.equals(sibling.getParent(), parent)) {
+ continue;
+ }
+ for (BlockVector3 point : selection) {
+ if (sibling.contains(point)) {
+ return sibling;
+ }
+ }
+ }
+ return null;
+ }
+
+ public static boolean regionIsFullyContainedByParent(@NotNull Region region,
+ @NotNull ProtectedRegion parent) {
+ if (region instanceof CuboidRegion cuboid) {
+ return checkCuboidFacesContained(parent,
+ cuboid.getMinimumPoint(), cuboid.getMaximumPoint());
+ } else if (region instanceof Polygonal2DRegion polygon) {
+ return checkPolygonSurfaceContained(parent, polygon);
+ }
+ for (BlockVector3 point : region) {
+ if (!parent.contains(point)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean checkCuboidFacesContained(ProtectedRegion region,
+ BlockVector3 min,
+ BlockVector3 max) {
+ for (int y = min.y(); y <= max.y(); y++) {
+ for (int z = min.z(); z <= max.z(); z++) {
+ if (!region.contains(min.x(), y, z) || !region.contains(max.x(), y, z)) {
+ return false;
+ }
+ }
+ }
+ for (int x = min.x(); x <= max.x(); x++) {
+ for (int z = min.z(); z <= max.z(); z++) {
+ if (!region.contains(x, min.y(), z) || !region.contains(x, max.y(), z)) {
+ return false;
+ }
+ }
+ }
+ for (int x = min.x(); x <= max.x(); x++) {
+ for (int y = min.y(); y <= max.y(); y++) {
+ if (!region.contains(x, y, min.z()) || !region.contains(x, y, max.z())) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ private static boolean checkPolygonSurfaceContained(ProtectedRegion parent,
+ Polygonal2DRegion polygon) {
+ List points = polygon.getPoints();
+ int minY = polygon.getMinimumY();
+ int maxY = polygon.getMaximumY();
+ BlockVector3 min = polygon.getMinimumPoint();
+ BlockVector3 max = polygon.getMaximumPoint();
+
+ for (int x = min.x(); x <= max.x(); x++) {
+ for (int z = min.z(); z <= max.z(); z++) {
+ if (polygon.contains(BlockVector3.at(x, minY, z))) {
+ if (!parent.contains(x, minY, z) || !parent.contains(x, maxY, z)) {
+ return false;
+ }
+ }
+ }
+ }
+
+ for (int i = 0; i < points.size(); i++) {
+ BlockVector2 a = points.get(i);
+ BlockVector2 b = points.get((i + 1) % points.size());
+ int dx = Math.abs(b.x() - a.x());
+ int dz = Math.abs(b.z() - a.z());
+ int sx = a.x() < b.x() ? 1 : -1;
+ int sz = a.z() < b.z() ? 1 : -1;
+ int err = dx - dz;
+ int cx = a.x();
+ int cz = a.z();
+ while (true) {
+ for (int y = minY; y <= maxY; y++) {
+ if (!parent.contains(cx, y, cz)) {
+ return false;
+ }
+ }
+ if (cx == b.x() && cz == b.z()) {
+ break;
+ }
+ int e2 = 2 * err;
+ if (e2 > -dz) {
+ err -= dz;
+ cx += sx;
+ }
+ if (e2 < dx) {
+ err += dx;
+ cz += sz;
+ }
+ }
+ }
+ return true;
+ }
+}
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/listener/SubregionWandListener.java b/realty-paper/src/main/java/io/github/md5sha256/realty/listener/SubregionWandListener.java
new file mode 100644
index 00000000..133f0f8c
--- /dev/null
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/listener/SubregionWandListener.java
@@ -0,0 +1,178 @@
+package io.github.md5sha256.realty.listener;
+
+import com.sk89q.worldedit.math.BlockVector3;
+import io.github.md5sha256.realty.localisation.MessageContainer;
+import io.github.md5sha256.realty.localisation.MessageKeys;
+import io.github.md5sha256.realty.wand.SubregionWand;
+import io.github.md5sha256.realty.wand.SubregionWandManager;
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.Particle;
+import org.bukkit.World;
+import org.bukkit.block.Block;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.event.block.Action;
+import org.bukkit.event.player.PlayerInteractEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.inventory.EquipmentSlot;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.plugin.Plugin;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.List;
+
+/**
+ * Drives the subregion selection wand: right-click marks a corner, left-click undoes the last one.
+ * Two marked points make a box; three or more make a polygon. A particle outline previews the
+ * footprint while the wand is held. Opening the height/create dialog is done via
+ * {@code /realty subregion confirm}.
+ */
+public final class SubregionWandListener implements Listener {
+
+ private static final long RENDER_PERIOD_TICKS = 10L;
+ private static final int MAX_POINTS_PER_EDGE = 32;
+
+ private final SubregionWand wand;
+ private final SubregionWandManager wandManager;
+ private final MessageContainer messages;
+
+ public SubregionWandListener(@NotNull Plugin plugin,
+ @NotNull SubregionWand wand,
+ @NotNull SubregionWandManager wandManager,
+ @NotNull MessageContainer messages) {
+ this.wand = wand;
+ this.wandManager = wandManager;
+ this.messages = messages;
+ plugin.getServer().getScheduler().runTaskTimer(plugin, this::renderSelections,
+ RENDER_PERIOD_TICKS, RENDER_PERIOD_TICKS);
+ }
+
+ @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
+ public void onPlayerInteract(@NotNull PlayerInteractEvent event) {
+ if (event.getHand() == EquipmentSlot.OFF_HAND) {
+ return;
+ }
+ ItemStack item = event.getItem();
+ if (!wand.isWand(item)) {
+ return;
+ }
+ Player player = event.getPlayer();
+ Action action = event.getAction();
+
+ if (action == Action.RIGHT_CLICK_BLOCK) {
+ event.setCancelled(true);
+ addPoint(player, event.getClickedBlock());
+ } else if (action == Action.LEFT_CLICK_BLOCK) {
+ event.setCancelled(true);
+ undoPoint(player);
+ }
+ }
+
+ @EventHandler
+ public void onQuit(@NotNull PlayerQuitEvent event) {
+ wandManager.clear(event.getPlayer().getUniqueId());
+ }
+
+ private void addPoint(@NotNull Player player, Block block) {
+ if (block == null) {
+ return;
+ }
+ // Feedback is the particle outline + action bar; no chat spam per corner.
+ BlockVector3 point = BlockVector3.at(block.getX(), block.getY(), block.getZ());
+ wandManager.addPoint(player.getUniqueId(), block.getWorld(), point);
+ }
+
+ private void undoPoint(@NotNull Player player) {
+ wandManager.removeLastPoint(player.getUniqueId());
+ }
+
+ private void renderSelections() {
+ for (Player player : Bukkit.getOnlinePlayers()) {
+ if (!wand.isWand(player.getInventory().getItemInMainHand())) {
+ continue;
+ }
+ SubregionWandManager.WandSelection selection = wandManager.get(player.getUniqueId());
+ int size = selection == null ? 0 : selection.size();
+ // Keep a hint on the action bar while the wand is held (refreshed before it fades).
+ player.sendActionBar(messages.messageFor(size >= 2
+ ? MessageKeys.SUBREGION_HINT_READY : MessageKeys.SUBREGION_HINT_PLACE));
+ if (selection == null || !selection.isComplete()
+ || selection.world() != player.getWorld()) {
+ continue;
+ }
+ // Before the height is set, preview the footprint as a flat ring at the marked level.
+ int markedLevel = selection.heightSet() ? 0 : selection.minPointY();
+ double floor = selection.heightSet() ? selection.floorY() : markedLevel;
+ double ceiling = (selection.heightSet() ? selection.ceilingY() : markedLevel) + 1;
+ List points = selection.points();
+ if (selection.isPolygon()) {
+ drawPolygon(player, selection.world(), points, floor, ceiling);
+ } else {
+ drawBox(player, selection.world(), points.get(0), points.get(1), floor, ceiling);
+ }
+ }
+ }
+
+ private void drawBox(@NotNull Player player, @NotNull World world,
+ @NotNull BlockVector3 a, @NotNull BlockVector3 b,
+ double floor, double ceiling) {
+ // Outer shell: a block at x spans [x, x+1), so the horizontal edges run min .. max+1.
+ double minX = Math.min(a.x(), b.x());
+ double minZ = Math.min(a.z(), b.z());
+ double maxX = Math.max(a.x(), b.x()) + 1;
+ double maxZ = Math.max(a.z(), b.z()) + 1;
+
+ double[] xs = {minX, maxX};
+ double[] ys = {floor, ceiling};
+ double[] zs = {minZ, maxZ};
+
+ for (double y : ys) {
+ for (double z : zs) {
+ edge(player, world, minX, y, z, maxX, y, z);
+ }
+ }
+ for (double x : xs) {
+ for (double z : zs) {
+ edge(player, world, x, floor, z, x, ceiling, z);
+ }
+ }
+ for (double x : xs) {
+ for (double y : ys) {
+ edge(player, world, x, y, minZ, x, y, maxZ);
+ }
+ }
+ }
+
+ private void drawPolygon(@NotNull Player player, @NotNull World world,
+ @NotNull List points, double minY, double maxY) {
+ int count = points.size();
+ for (int i = 0; i < count; i++) {
+ BlockVector3 current = points.get(i);
+ BlockVector3 next = points.get((i + 1) % count);
+ double cx = current.x() + 0.5;
+ double cz = current.z() + 0.5;
+ double nx = next.x() + 0.5;
+ double nz = next.z() + 0.5;
+ // Vertical pillar at each vertex.
+ edge(player, world, cx, minY, cz, cx, maxY, cz);
+ // Footprint edges along the floor and ceiling.
+ edge(player, world, cx, minY, cz, nx, minY, nz);
+ edge(player, world, cx, maxY, cz, nx, maxY, nz);
+ }
+ }
+
+ private void edge(@NotNull Player player, @NotNull World world,
+ double x1, double y1, double z1, double x2, double y2, double z2) {
+ double length = Math.max(Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1)), Math.abs(z2 - z1));
+ int steps = (int) Math.min(MAX_POINTS_PER_EDGE, Math.max(1, length));
+ for (int i = 0; i <= steps; i++) {
+ double t = (double) i / steps;
+ Location loc = new Location(world,
+ x1 + (x2 - x1) * t, y1 + (y2 - y1) * t, z1 + (z2 - z1) * t);
+ player.spawnParticle(Particle.HAPPY_VILLAGER, loc, 1, 0, 0, 0, 0);
+ }
+ }
+}
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java b/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java
index 1d9ec2f7..ad54b4ac 100644
--- a/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java
@@ -380,12 +380,17 @@ private MessageKeys() {}
public static final String SUBREGION_INVALID_NAME = "subregion.invalid-name";
public static final String SUBREGION_NO_FREEHOLD = "subregion.no-freehold";
public static final String SUBREGION_NOT_TITLEHOLDER = "subregion.not-titleholder";
- public static final String SUBREGION_WRONG_WORLD = "subregion.wrong-world";
- public static final String SUBREGION_INCOMPLETE_SELECTION = "subregion.incomplete-selection";
public static final String SUBREGION_EXCEEDS_BOUNDS = "subregion.exceeds-bounds";
public static final String SUBREGION_OVERLAPS_SIBLING = "subregion.overlaps-sibling";
public static final String SUBREGION_TOO_SMALL = "subregion.too-small";
public static final String SUBREGION_TAG_BLACKLISTED = "subregion.tag-blacklisted";
+ public static final String SUBREGION_WAND_GIVEN = "subregion.wand-given";
+ public static final String SUBREGION_SELECTION_CLEARED = "subregion.selection-cleared";
+ public static final String SUBREGION_NOTHING_TO_CLEAR = "subregion.nothing-to-clear";
+ public static final String SUBREGION_HINT_PLACE = "subregion.hint-place";
+ public static final String SUBREGION_HINT_READY = "subregion.hint-ready";
+ public static final String SUBREGION_SELECTION_INCOMPLETE = "subregion.selection-incomplete";
+ public static final String SUBREGION_NO_PARENT_CANDIDATES = "subregion.no-parent-candidates";
// teleport
public static final String TP_SUCCESS = "tp.success";
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/settings/Settings.java b/realty-paper/src/main/java/io/github/md5sha256/realty/settings/Settings.java
index ebb22a80..a548b47a 100644
--- a/realty-paper/src/main/java/io/github/md5sha256/realty/settings/Settings.java
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/settings/Settings.java
@@ -22,6 +22,7 @@ public record Settings(
@Setting("subregion-min-volume") int subregionMinVolume,
@Setting("offer-payment-duration-seconds") long offerPaymentDurationSeconds,
@Setting("subregion-tag-blacklist") @NotNull List subregionTagBlacklist,
+ @Setting("subregion-wand-material") @NotNull String subregionWandMaterial,
@Setting("teleportation-starting-height") int teleportStartHeight
) {
@@ -38,6 +39,9 @@ public record Settings(
if (subregionTagBlacklist == null) {
subregionTagBlacklist = List.of();
}
+ if (subregionWandMaterial == null || subregionWandMaterial.isBlank()) {
+ subregionWandMaterial = "GOLDEN_AXE";
+ }
}
}
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWand.java
new file mode 100644
index 00000000..42e98343
--- /dev/null
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWand.java
@@ -0,0 +1,75 @@
+package io.github.md5sha256.realty.wand;
+
+import io.github.md5sha256.realty.settings.Settings;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextDecoration;
+import org.bukkit.Material;
+import org.bukkit.NamespacedKey;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.persistence.PersistentDataContainer;
+import org.bukkit.persistence.PersistentDataType;
+import org.bukkit.plugin.Plugin;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Builds the subregion selection wand and identifies it later. The wand is a plain item tagged via
+ * the {@link PersistentDataContainer} so it survives renaming/moving and can't be spoofed by name.
+ */
+public final class SubregionWand {
+
+ private final NamespacedKey wandKey;
+ private final AtomicReference settings;
+
+ public SubregionWand(@NotNull Plugin plugin, @NotNull AtomicReference settings) {
+ this.wandKey = new NamespacedKey(plugin, "subregion_wand");
+ this.settings = settings;
+ }
+
+ /**
+ * Creates a fresh wand item using the configured material.
+ */
+ public @NotNull ItemStack createWand() {
+ Material material = resolveMaterial(settings.get().subregionWandMaterial());
+ ItemStack item = new ItemStack(material);
+ item.editMeta(meta -> {
+ meta.displayName(Component.text("Subregion Wand", NamedTextColor.AQUA)
+ .decoration(TextDecoration.ITALIC, false));
+ meta.lore(List.of(
+ Component.text("Right-click: place a corner", NamedTextColor.GRAY)
+ .decoration(TextDecoration.ITALIC, false),
+ Component.text("Left-click: undo last corner", NamedTextColor.GRAY)
+ .decoration(TextDecoration.ITALIC, false),
+ Component.text("Clear all: /realty subregion clear", NamedTextColor.GRAY)
+ .decoration(TextDecoration.ITALIC, false),
+ Component.text("Finish: /realty subregion confirm", NamedTextColor.GRAY)
+ .decoration(TextDecoration.ITALIC, false)));
+ meta.getPersistentDataContainer().set(wandKey, PersistentDataType.BYTE, (byte) 1);
+ });
+ return item;
+ }
+
+ /**
+ * Returns {@code true} if the given item is a subregion wand (by PDC tag, not by name).
+ */
+ public boolean isWand(@Nullable ItemStack item) {
+ if (item == null || !item.hasItemMeta()) {
+ return false;
+ }
+ Byte tag = item.getItemMeta().getPersistentDataContainer()
+ .get(wandKey, PersistentDataType.BYTE);
+ return tag != null;
+ }
+
+ private static @NotNull Material resolveMaterial(@NotNull String name) {
+ Material material = Material.matchMaterial(name);
+ if (material == null || !material.isItem()) {
+ return Material.GOLDEN_AXE;
+ }
+ return material;
+ }
+}
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWandManager.java b/realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWandManager.java
new file mode 100644
index 00000000..f92138ce
--- /dev/null
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWandManager.java
@@ -0,0 +1,170 @@
+package io.github.md5sha256.realty.wand;
+
+import com.sk89q.worldedit.bukkit.BukkitAdapter;
+import com.sk89q.worldedit.math.BlockVector2;
+import com.sk89q.worldedit.math.BlockVector3;
+import com.sk89q.worldedit.regions.CuboidRegion;
+import com.sk89q.worldedit.regions.Polygonal2DRegion;
+import com.sk89q.worldedit.regions.Region;
+import org.bukkit.World;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.UnmodifiableView;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Holds each player's in-progress wand selection: an ordered footprint of marked points plus a
+ * vertical span (floor/ceiling) set separately via the height dialog.
+ *
+ *
The footprint shape is inferred from the point count — exactly two points define a rectangle
+ * (opposite corners), three or more define an arbitrary polygon. The height is not taken
+ * from where the player clicked; players mark the footprint on the ground and set the vertical
+ * span afterwards, so there's never a need to click a block floating in the air.
+ *
+ *
A plain {@link HashMap} is intentional: every access happens on the server main thread —
+ * point capture in the interact event, the particle render task, and dialog open. The dialog's
+ * single async stage captures the {@link Region} before leaving the main thread, so this map is
+ * never touched concurrently.
+ */
+public final class SubregionWandManager {
+
+ /**
+ * A selection in progress: the world, the marked footprint points, and the (optional) height.
+ */
+ public static final class WandSelection {
+ private final World world;
+ private final List points = new ArrayList<>();
+ private Integer floorY;
+ private Integer ceilingY;
+
+ private WandSelection(@NotNull World world) {
+ this.world = world;
+ }
+
+ public @NotNull World world() {
+ return world;
+ }
+
+ public @NotNull @UnmodifiableView List points() {
+ return Collections.unmodifiableList(points);
+ }
+
+ public int size() {
+ return points.size();
+ }
+
+ /** Two points make a rectangle; three or more make a polygon. */
+ public boolean isComplete() {
+ return points.size() >= 2;
+ }
+
+ public boolean isPolygon() {
+ return points.size() >= 3;
+ }
+
+ public boolean heightSet() {
+ return floorY != null && ceilingY != null;
+ }
+
+ /** Footprint and height both set — ready to build a region. */
+ public boolean isReady() {
+ return isComplete() && heightSet();
+ }
+
+ public @Nullable Integer floorY() {
+ return floorY;
+ }
+
+ public @Nullable Integer ceilingY() {
+ return ceilingY;
+ }
+
+ public void setHeight(int floor, int ceiling) {
+ this.floorY = Math.min(floor, ceiling);
+ this.ceilingY = Math.max(floor, ceiling);
+ }
+
+ public int minPointY() {
+ int min = Integer.MAX_VALUE;
+ for (BlockVector3 point : points) {
+ min = Math.min(min, point.y());
+ }
+ return min == Integer.MAX_VALUE ? 0 : min;
+ }
+
+ public int maxPointY() {
+ int max = Integer.MIN_VALUE;
+ for (BlockVector3 point : points) {
+ max = Math.max(max, point.y());
+ }
+ return max == Integer.MIN_VALUE ? 0 : max;
+ }
+
+ public @NotNull Region toRegion() {
+ if (!isReady()) {
+ throw new IllegalStateException("Selection is incomplete");
+ }
+ if (points.size() == 2) {
+ BlockVector3 a = points.get(0);
+ BlockVector3 b = points.get(1);
+ BlockVector3 min = BlockVector3.at(Math.min(a.x(), b.x()), floorY, Math.min(a.z(), b.z()));
+ BlockVector3 max = BlockVector3.at(Math.max(a.x(), b.x()), ceilingY, Math.max(a.z(), b.z()));
+ return new CuboidRegion(min, max);
+ }
+ List footprint = new ArrayList<>(points.size());
+ for (BlockVector3 point : points) {
+ footprint.add(BlockVector2.at(point.x(), point.z()));
+ }
+ return new Polygonal2DRegion(BukkitAdapter.adapt(world), footprint, floorY, ceilingY);
+ }
+ }
+
+ private final Map selections = new HashMap<>();
+
+ /**
+ * Appends a marked footprint point for the player. If the player's stored selection is in a
+ * different world (they moved worlds), it is reset to the new world first.
+ */
+ public @NotNull WandSelection addPoint(@NotNull UUID playerId, @NotNull World world,
+ @NotNull BlockVector3 point) {
+ WandSelection selection = selections.get(playerId);
+ if (selection == null || selection.world != world) {
+ selection = new WandSelection(world);
+ selections.put(playerId, selection);
+ }
+ selection.points.add(point);
+ return selection;
+ }
+
+ /**
+ * Removes the player's most recently marked point. Returns the selection (possibly now empty),
+ * or {@code null} if there was nothing to remove.
+ */
+ public @Nullable WandSelection removeLastPoint(@NotNull UUID playerId) {
+ WandSelection selection = selections.get(playerId);
+ if (selection == null || selection.points.isEmpty()) {
+ return null;
+ }
+ selection.points.remove(selection.points.size() - 1);
+ return selection;
+ }
+
+ public @Nullable WandSelection get(@NotNull UUID playerId) {
+ return selections.get(playerId);
+ }
+
+ public boolean isComplete(@NotNull UUID playerId) {
+ WandSelection selection = selections.get(playerId);
+ return selection != null && selection.isComplete();
+ }
+
+ public void clear(@NotNull UUID playerId) {
+ selections.remove(playerId);
+ }
+}
diff --git a/realty-paper/src/main/resources/messages.yml b/realty-paper/src/main/resources/messages.yml
index 2688216e..13edd6c8 100644
--- a/realty-paper/src/main/resources/messages.yml
+++ b/realty-paper/src/main/resources/messages.yml
@@ -464,18 +464,23 @@ notification:
leasehold-expired-landlord: " The lease on region has expired; the tenant has been removed."
subregion:
- create-success: "Subregion created as a child of ."
- create-error: "Failed to create subregion: "
- region-exists: "A region named already exists."
- invalid-name: "Region name is invalid. Names must contain letters, numbers and dashes only."
- no-freehold: "Region is not a freehold region."
- not-titleholder: "You are not the titleholder of region ."
- wrong-world: "Your WorldEdit selection is in a different world than the parent region."
- incomplete-selection: "Your WorldEdit selection is incomplete. Select both points first."
- exceeds-bounds: "Your selection exceeds the bounds of region ."
- overlaps-sibling: "Your selection overlaps with existing subregion ."
- too-small: "Your selection is too small ( blocks). Minimum volume is blocks."
- tag-blacklisted: "Region has tag which prevents subregioning."
+ create-success: " is now up for rent."
+ create-error: "That didn't work: "
+ region-exists: "That name's taken. Pick another."
+ invalid-name: "Names can only use letters, numbers and dashes."
+ no-freehold: "You can only do this in a region you own."
+ not-titleholder: "That's not your region."
+ exceeds-bounds: "That goes outside your region."
+ overlaps-sibling: "That overlaps ."
+ too-small: "Too small. It needs at least blocks."
+ tag-blacklisted: "This region can't be split up."
+ wand-given: " Right-click to add corners, left-click to undo, then run /realty subregion confirm."
+ selection-cleared: " Selection cleared."
+ nothing-to-clear: " Nothing to clear."
+ selection-incomplete: " You need at least 2 corners first."
+ no-parent-candidates: " You need to be standing in a region you own."
+ hint-place: "Right-click to place corners, left-click to undo"
+ hint-ready: "Run /realty subregion confirm when you're finished"
sign:
place-success: "Sign registered for region ."
diff --git a/realty-paper/src/main/resources/paper-plugin.yml b/realty-paper/src/main/resources/paper-plugin.yml
index a31be946..5e4b5a65 100644
--- a/realty-paper/src/main/resources/paper-plugin.yml
+++ b/realty-paper/src/main/resources/paper-plugin.yml
@@ -225,10 +225,13 @@ permissions:
realty.command.tag.clear:
description: Allows using /realty tag clear
default: op
- realty.command.subregion.quickcreate:
- description: Allows using /realty subregion quickcreate
+ realty.command.subregion.wand:
+ description: Allows using /realty subregion wand
default: op
- realty.command.subregion.quickcreate.bypass:
+ realty.command.subregion.confirm:
+ description: Allows using /realty subregion confirm
+ default: op
+ realty.command.subregion.confirm.bypass:
description: Allows subregioning on freehold regions you don't own
default: op
realty.command.tp:
diff --git a/realty-paper/src/main/resources/settings.yml b/realty-paper/src/main/resources/settings.yml
index 20b588b6..7d7d485d 100644
--- a/realty-paper/src/main/resources/settings.yml
+++ b/realty-paper/src/main/resources/settings.yml
@@ -13,6 +13,9 @@ offer-payment-duration-seconds: 86400
# Tag IDs that prevent a parent region from being subregioned.
# If a region has any of these tags, it cannot be used as a parent for subregion creation.
subregion-tag-blacklist: []
+# Material of the subregion selection wand handed out by /realty subregion wand.
+# Use a material distinct from WorldEdit's wand (default wooden axe) to avoid confusion.
+subregion-wand-material: "GOLDEN_AXE"
# Y-level at which the safe location finder will start looking in the case of very tall plots.
# It's advisable to set this just above the average ground level of your map.
teleportation-starting-height: 80
\ No newline at end of file
From 120c745d696b0a65dd34b48af563bcfcf70a63b1 Mon Sep 17 00:00:00 2001
From: Technofied <40795318+Technofied@users.noreply.github.com>
Date: Fri, 26 Jun 2026 15:54:28 +0800
Subject: [PATCH 2/3] fix: leasehold creation no longer adds an expiry date
---
.../database/maria/mapper/MariaLeaseholdContractMapper.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java
index 42ed2034..b0f0fb00 100644
--- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java
+++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java
@@ -25,8 +25,8 @@ INSERT INTO LeaseholdContract (landlordId, tenantId, price, durationSeconds, sta
#{tenantId},
#{price},
#{durationSeconds},
- NOW(),
- NOW() + INTERVAL #{durationSeconds} SECOND,
+ CASE WHEN #{tenantId} IS NULL THEN NULL ELSE NOW() END,
+ CASE WHEN #{tenantId} IS NULL THEN NULL ELSE NOW() + INTERVAL #{durationSeconds} SECOND END,
CASE WHEN #{maxRenewals} >= 0 THEN 0 ELSE NULL END,
CASE WHEN #{maxRenewals} >= 0 THEN #{maxRenewals} ELSE NULL END
)
From 04c0a866dba7736cf95303c2f2d371750ac3acc1 Mon Sep 17 00:00:00 2001
From: Technofied <40795318+Technofied@users.noreply.github.com>
Date: Fri, 26 Jun 2026 16:33:14 +0800
Subject: [PATCH 3/3] refactor: extract nested classes, dedupe tag insert,
defensive-copy points
---
.../database/mapper/RegionTagMapper.java | 3 +
.../maria/mapper/MariaRegionTagMapper.java | 8 ++
.../realty/command/DurationUnit.java | 17 +++
.../realty/command/SubregionDialog.java | 126 ++++++++----------
.../realty/command/SubregionState.java | 31 +++++
.../listener/SubregionWandListener.java | 3 +-
.../realty/wand/SubregionWandManager.java | 119 +----------------
.../md5sha256/realty/wand/WandSelection.java | 125 +++++++++++++++++
8 files changed, 244 insertions(+), 188 deletions(-)
create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/command/DurationUnit.java
create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionState.java
create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/wand/WandSelection.java
diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/RegionTagMapper.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/RegionTagMapper.java
index 0084b8e5..c74d1fe2 100644
--- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/RegionTagMapper.java
+++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/RegionTagMapper.java
@@ -11,6 +11,9 @@ public interface RegionTagMapper {
int insert(@NotNull String tagId, @NotNull String worldGuardRegionId);
+ /** Inserts the tag if the region doesn't already have it; a no-op (0 rows) when it does. */
+ int insertIfAbsent(@NotNull String tagId, @NotNull String worldGuardRegionId);
+
@NotNull List selectRegionIdsByTagId(@NotNull String tagId);
@NotNull List selectTagIdsByRegionId(@NotNull String worldGuardRegionId);
diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaRegionTagMapper.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaRegionTagMapper.java
index 46512348..bfd1d913 100644
--- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaRegionTagMapper.java
+++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaRegionTagMapper.java
@@ -30,6 +30,14 @@ INSERT INTO RegionTag (tagId, worldGuardRegionId)
int insert(@Param("tagId") @NotNull String tagId,
@Param("worldGuardRegionId") @NotNull String worldGuardRegionId);
+ @Override
+ @Insert("""
+ INSERT IGNORE INTO RegionTag (tagId, worldGuardRegionId)
+ VALUES (#{tagId}, #{worldGuardRegionId})
+ """)
+ int insertIfAbsent(@Param("tagId") @NotNull String tagId,
+ @Param("worldGuardRegionId") @NotNull String worldGuardRegionId);
+
@Override
@Select("""
SELECT worldGuardRegionId
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/DurationUnit.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/DurationUnit.java
new file mode 100644
index 00000000..bb4c3917
--- /dev/null
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/DurationUnit.java
@@ -0,0 +1,17 @@
+package io.github.md5sha256.realty.command;
+
+/** Lease-duration units offered in the subregion create dialog. */
+enum DurationUnit {
+ MINUTES(60L, "Minutes"),
+ HOURS(3600L, "Hours"),
+ DAYS(86400L, "Days"),
+ WEEKS(604800L, "Weeks");
+
+ final long seconds;
+ final String label;
+
+ DurationUnit(long seconds, String label) {
+ this.seconds = seconds;
+ this.label = label;
+ }
+}
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionDialog.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionDialog.java
index fa5694b3..397b1cfe 100644
--- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionDialog.java
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionDialog.java
@@ -36,6 +36,7 @@
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import java.time.Duration;
import java.util.ArrayList;
@@ -50,6 +51,7 @@
import java.util.regex.Pattern;
import io.github.md5sha256.realty.wand.SubregionWandManager;
+import io.github.md5sha256.realty.wand.WandSelection;
/**
* Guided two-page dialog for creating a subregion from the player's wand selection.
@@ -71,6 +73,7 @@ public final class SubregionDialog {
private static final String INPUT_UNLIMITED_RENEWALS = "unlimited_renewals";
private static final String INPUT_MAX_RENEWALS = "max_renewals";
private static final String INPUT_HEIGHT = "height";
+ private static final String TAG_INPUT_PREFIX = "tag_";
private static final int DEFAULT_HEIGHT = 16;
private static final int FORM_INPUT_WIDTH = 200;
/** Stored as the lease's max renewals to mean "no cap"; the backend maps any negative to NULL. */
@@ -78,24 +81,6 @@ public final class SubregionDialog {
private static final ClickCallback.Options CLICK_OPTIONS =
ClickCallback.Options.builder().uses(ClickCallback.UNLIMITED_USES).build();
- /** Lease-duration units offered in the create dialog. */
- enum DurationUnit {
- MINUTES(60L, "Minutes"),
- HOURS(3600L, "Hours"),
- DAYS(86400L, "Days"),
- WEEKS(604800L, "Weeks");
-
- final long seconds;
- final String label;
-
- DurationUnit(long seconds, String label) {
- this.seconds = seconds;
- this.label = label;
- }
- }
-
- private static final String TAG_INPUT_PREFIX = "tag_";
-
private final RealtyPaperApi api;
private final ExecutorState executorState;
private final Database database;
@@ -105,22 +90,6 @@ enum DurationUnit {
private final MessageContainer messages;
private final ConcurrentHashMap playerStates = new ConcurrentHashMap<>();
- static final class SubregionState {
- Region selection;
- World world;
- UUID worldId;
- final List parentCandidates = new ArrayList<>();
- String parentId;
- String name = "";
- String price = "100";
- String durationAmount = "30";
- String durationUnit = DurationUnit.DAYS.name();
- boolean unlimitedRenewals = true;
- String maxRenewals = "3";
- final List permittedTagIds = new ArrayList<>();
- final Set selectedTags = new LinkedHashSet<>();
- }
-
public SubregionDialog(@NotNull RealtyPaperApi api,
@NotNull ExecutorState executorState,
@NotNull Database database,
@@ -142,7 +111,7 @@ public SubregionDialog(@NotNull RealtyPaperApi api,
* current wand selection.
*/
public void open(@NotNull Player player) {
- SubregionWandManager.WandSelection wandSelection = wandManager.get(player.getUniqueId());
+ WandSelection wandSelection = wandManager.get(player.getUniqueId());
if (wandSelection == null || !wandSelection.isComplete()) {
player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_SELECTION_INCOMPLETE));
return;
@@ -241,7 +210,7 @@ public void open(@NotNull Player player) {
* Opens the height dialog for the player's current footprint selection.
*/
public void openHeight(@NotNull Player player) {
- SubregionWandManager.WandSelection selection = wandManager.get(player.getUniqueId());
+ WandSelection selection = wandManager.get(player.getUniqueId());
if (selection == null || !selection.isComplete()) {
player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_SELECTION_INCOMPLETE));
return;
@@ -250,7 +219,7 @@ public void openHeight(@NotNull Player player) {
}
private void showHeightDialog(@NotNull Player player,
- @NotNull SubregionWandManager.WandSelection selection) {
+ @NotNull WandSelection selection) {
World world = selection.world();
// Floor is the lowest corner the player marked; the slider only sets how tall it is.
int baseFloor = selection.minPointY();
@@ -314,7 +283,7 @@ private void showHeightDialog(@NotNull Player player,
player.showDialog(dialog);
}
- private void saveHeight(@NotNull SubregionWandManager.WandSelection selection,
+ private void saveHeight(@NotNull WandSelection selection,
@NotNull DialogResponseView response,
int baseFloor, int worldMax) {
Float raw = response.getFloat(INPUT_HEIGHT);
@@ -364,7 +333,10 @@ private void showCreateDialog(@NotNull Player player, @NotNull SubregionState st
DialogActionCallback nextCallback = (response, audience) -> {
saveCreate(state, response);
RegionManager regionManager = regionManager(state.world);
- if (regionManager == null || !validate(player, state, regionManager)) {
+ state.error = regionManager == null
+ ? error("Region manager unavailable")
+ : validate(state, regionManager);
+ if (state.error != null) {
showCreateDialog(player, state);
return;
}
@@ -387,12 +359,19 @@ private void showCreateDialog(@NotNull Player player, @NotNull SubregionState st
.build());
}
+ List body = new ArrayList<>();
+ if (state.error != null) {
+ body.add(DialogBody.plainMessage(state.error.colorIfAbsent(NamedTextColor.RED)));
+ }
+ body.add(DialogBody.plainMessage(Component.text("Landlord: " + player.getName())));
+ // The error is a one-shot for this reopen; don't keep it around for Back/Done navigation.
+ state.error = null;
+
Dialog dialog = Dialog.create(factory -> factory.empty()
.base(DialogBase.builder(Component.text("Step 2 of 3: Details"))
.canCloseWithEscape(true)
.afterAction(DialogBase.DialogAfterAction.CLOSE)
- .body(List.of(DialogBody.plainMessage(Component.text(
- "Landlord: " + player.getName()))))
+ .body(body)
.inputs(inputs)
.build())
.type(DialogType.multiAction(actions,
@@ -497,13 +476,19 @@ private void showConfirmDialog(@NotNull Player player, @NotNull SubregionState s
private void submit(@NotNull Player player, @NotNull SubregionState state) {
RegionManager regionManager = regionManager(state.world);
- if (regionManager == null || !validate(player, state, regionManager)) {
+ state.error = regionManager == null
+ ? error("Region manager unavailable")
+ : validate(state, regionManager);
+ if (state.error != null) {
+ // Reopen the details dialog so the error is visible rather than hidden behind it.
+ showCreateDialog(player, state);
return;
}
ProtectedRegion parent = regionManager.getRegion(state.parentId);
if (parent == null) {
- player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_NO_FREEHOLD,
- Placeholder.unparsed("region", state.parentId)));
+ state.error = messages.messageFor(MessageKeys.SUBREGION_NO_FREEHOLD,
+ Placeholder.unparsed("region", state.parentId));
+ showCreateDialog(player, state);
return;
}
WorldGuardRegion parentRegion = new WorldGuardRegion(parent, state.world);
@@ -551,61 +536,54 @@ private void applyTags(@NotNull String regionId, @NotNull Set tagIds) {
try (SqlSessionWrapper session = database.openSession(true)) {
RegionTagMapper mapper = session.regionTagMapper();
for (String tagId : tagIds) {
- if (!mapper.exists(tagId, regionId)) {
- mapper.insert(tagId, regionId);
- }
+ mapper.insertIfAbsent(tagId, regionId);
}
}
});
}
/**
- * Validates the current form, messaging the player and returning {@code false} on the first
- * problem. Geometry/ownership were already enforced at {@link #open}; this re-checks the
- * user-entered fields plus name uniqueness and sibling overlap against the chosen parent.
+ * Validates the current form. Returns {@code null} when everything is valid, otherwise the
+ * error message to show at the top of the details dialog. Geometry/ownership were already
+ * enforced at {@link #open}; this re-checks the user-entered fields plus name uniqueness and
+ * sibling overlap against the chosen parent.
*/
- private boolean validate(@NotNull Player player, @NotNull SubregionState state,
- @NotNull RegionManager regionManager) {
+ private @Nullable Component validate(@NotNull SubregionState state,
+ @NotNull RegionManager regionManager) {
if (state.name == null || !VALID_NAME_PATTERN.matcher(state.name).matches()) {
- player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_INVALID_NAME,
- Placeholder.unparsed("region", String.valueOf(state.name))));
- return false;
+ return messages.messageFor(MessageKeys.SUBREGION_INVALID_NAME,
+ Placeholder.unparsed("region", String.valueOf(state.name)));
}
if (regionManager.getRegion(state.name) != null) {
- player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_REGION_EXISTS,
- Placeholder.unparsed("region", state.name)));
- return false;
+ return messages.messageFor(MessageKeys.SUBREGION_REGION_EXISTS,
+ Placeholder.unparsed("region", state.name));
}
ProtectedRegion parent = regionManager.getRegion(state.parentId);
if (parent == null) {
- player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_NO_FREEHOLD,
- Placeholder.unparsed("region", String.valueOf(state.parentId))));
- return false;
+ return messages.messageFor(MessageKeys.SUBREGION_NO_FREEHOLD,
+ Placeholder.unparsed("region", String.valueOf(state.parentId)));
}
ProtectedRegion sibling = SubregionSelectionValidator.overlappingSibling(
state.selection, parent, regionManager);
if (sibling != null) {
- player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_OVERLAPS_SIBLING,
- Placeholder.unparsed("sibling", sibling.getId())));
- return false;
+ return messages.messageFor(MessageKeys.SUBREGION_OVERLAPS_SIBLING,
+ Placeholder.unparsed("sibling", sibling.getId()));
}
if (parsePrice(state.price) <= 0) {
- player.sendMessage(messages.messageFor(MessageKeys.COMMON_ERROR,
- Placeholder.unparsed("error", "Price must be more than 0.")));
- return false;
+ return error("Price must be more than 0.");
}
Duration duration = resolveDuration(state);
if (duration == null || duration.isZero() || duration.isNegative()) {
- player.sendMessage(messages.messageFor(MessageKeys.COMMON_ERROR,
- Placeholder.unparsed("error", "Lease length must be more than 0.")));
- return false;
+ return error("Lease length must be more than 0.");
}
if (resolveMaxRenewals(state) == null) {
- player.sendMessage(messages.messageFor(MessageKeys.COMMON_ERROR,
- Placeholder.unparsed("error", "Max renewals must be 0 or more.")));
- return false;
+ return error("Max renewals must be 0 or more.");
}
- return true;
+ return null;
+ }
+
+ private @NotNull Component error(@NotNull String text) {
+ return messages.messageFor(MessageKeys.COMMON_ERROR, Placeholder.unparsed("error", text));
}
private void saveCreate(@NotNull SubregionState state, @NotNull DialogResponseView response) {
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionState.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionState.java
new file mode 100644
index 00000000..626267e2
--- /dev/null
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionState.java
@@ -0,0 +1,31 @@
+package io.github.md5sha256.realty.command;
+
+import com.sk89q.worldedit.regions.Region;
+import net.kyori.adventure.text.Component;
+import org.bukkit.World;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+/** Per-player wizard state tracked by {@link SubregionDialog} between its dialog pages. */
+final class SubregionState {
+ Region selection;
+ World world;
+ UUID worldId;
+ final List parentCandidates = new ArrayList<>();
+ String parentId;
+ String name = "";
+ String price = "100";
+ String durationAmount = "30";
+ String durationUnit = DurationUnit.DAYS.name();
+ boolean unlimitedRenewals = true;
+ String maxRenewals = "3";
+ final List permittedTagIds = new ArrayList<>();
+ final Set selectedTags = new LinkedHashSet<>();
+ // Shown at the top of the details dialog when validation fails, so the message isn't hidden
+ // behind the reopened dialog. Cleared once rendered.
+ Component error;
+}
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/listener/SubregionWandListener.java b/realty-paper/src/main/java/io/github/md5sha256/realty/listener/SubregionWandListener.java
index 133f0f8c..a830f6bc 100644
--- a/realty-paper/src/main/java/io/github/md5sha256/realty/listener/SubregionWandListener.java
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/listener/SubregionWandListener.java
@@ -5,6 +5,7 @@
import io.github.md5sha256.realty.localisation.MessageKeys;
import io.github.md5sha256.realty.wand.SubregionWand;
import io.github.md5sha256.realty.wand.SubregionWandManager;
+import io.github.md5sha256.realty.wand.WandSelection;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Particle;
@@ -94,7 +95,7 @@ private void renderSelections() {
if (!wand.isWand(player.getInventory().getItemInMainHand())) {
continue;
}
- SubregionWandManager.WandSelection selection = wandManager.get(player.getUniqueId());
+ WandSelection selection = wandManager.get(player.getUniqueId());
int size = selection == null ? 0 : selection.size();
// Keep a hint on the action bar while the wand is held (refreshed before it fades).
player.sendActionBar(messages.messageFor(size >= 2
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWandManager.java b/realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWandManager.java
index f92138ce..de097ceb 100644
--- a/realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWandManager.java
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWandManager.java
@@ -1,130 +1,24 @@
package io.github.md5sha256.realty.wand;
-import com.sk89q.worldedit.bukkit.BukkitAdapter;
-import com.sk89q.worldedit.math.BlockVector2;
import com.sk89q.worldedit.math.BlockVector3;
-import com.sk89q.worldedit.regions.CuboidRegion;
-import com.sk89q.worldedit.regions.Polygonal2DRegion;
-import com.sk89q.worldedit.regions.Region;
import org.bukkit.World;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
-import org.jetbrains.annotations.UnmodifiableView;
-import java.util.ArrayList;
-import java.util.Collections;
import java.util.HashMap;
-import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
- * Holds each player's in-progress wand selection: an ordered footprint of marked points plus a
- * vertical span (floor/ceiling) set separately via the height dialog.
- *
- *
The footprint shape is inferred from the point count — exactly two points define a rectangle
- * (opposite corners), three or more define an arbitrary polygon. The height is not taken
- * from where the player clicked; players mark the footprint on the ground and set the vertical
- * span afterwards, so there's never a need to click a block floating in the air.
+ * Holds each player's in-progress {@link WandSelection}.
*
*
A plain {@link HashMap} is intentional: every access happens on the server main thread —
* point capture in the interact event, the particle render task, and dialog open. The dialog's
- * single async stage captures the {@link Region} before leaving the main thread, so this map is
- * never touched concurrently.
+ * single async stage captures the region before leaving the main thread, so this map is never
+ * touched concurrently.
*/
public final class SubregionWandManager {
- /**
- * A selection in progress: the world, the marked footprint points, and the (optional) height.
- */
- public static final class WandSelection {
- private final World world;
- private final List points = new ArrayList<>();
- private Integer floorY;
- private Integer ceilingY;
-
- private WandSelection(@NotNull World world) {
- this.world = world;
- }
-
- public @NotNull World world() {
- return world;
- }
-
- public @NotNull @UnmodifiableView List points() {
- return Collections.unmodifiableList(points);
- }
-
- public int size() {
- return points.size();
- }
-
- /** Two points make a rectangle; three or more make a polygon. */
- public boolean isComplete() {
- return points.size() >= 2;
- }
-
- public boolean isPolygon() {
- return points.size() >= 3;
- }
-
- public boolean heightSet() {
- return floorY != null && ceilingY != null;
- }
-
- /** Footprint and height both set — ready to build a region. */
- public boolean isReady() {
- return isComplete() && heightSet();
- }
-
- public @Nullable Integer floorY() {
- return floorY;
- }
-
- public @Nullable Integer ceilingY() {
- return ceilingY;
- }
-
- public void setHeight(int floor, int ceiling) {
- this.floorY = Math.min(floor, ceiling);
- this.ceilingY = Math.max(floor, ceiling);
- }
-
- public int minPointY() {
- int min = Integer.MAX_VALUE;
- for (BlockVector3 point : points) {
- min = Math.min(min, point.y());
- }
- return min == Integer.MAX_VALUE ? 0 : min;
- }
-
- public int maxPointY() {
- int max = Integer.MIN_VALUE;
- for (BlockVector3 point : points) {
- max = Math.max(max, point.y());
- }
- return max == Integer.MIN_VALUE ? 0 : max;
- }
-
- public @NotNull Region toRegion() {
- if (!isReady()) {
- throw new IllegalStateException("Selection is incomplete");
- }
- if (points.size() == 2) {
- BlockVector3 a = points.get(0);
- BlockVector3 b = points.get(1);
- BlockVector3 min = BlockVector3.at(Math.min(a.x(), b.x()), floorY, Math.min(a.z(), b.z()));
- BlockVector3 max = BlockVector3.at(Math.max(a.x(), b.x()), ceilingY, Math.max(a.z(), b.z()));
- return new CuboidRegion(min, max);
- }
- List footprint = new ArrayList<>(points.size());
- for (BlockVector3 point : points) {
- footprint.add(BlockVector2.at(point.x(), point.z()));
- }
- return new Polygonal2DRegion(BukkitAdapter.adapt(world), footprint, floorY, ceilingY);
- }
- }
-
private final Map selections = new HashMap<>();
/**
@@ -134,11 +28,11 @@ public int maxPointY() {
public @NotNull WandSelection addPoint(@NotNull UUID playerId, @NotNull World world,
@NotNull BlockVector3 point) {
WandSelection selection = selections.get(playerId);
- if (selection == null || selection.world != world) {
+ if (selection == null || selection.world() != world) {
selection = new WandSelection(world);
selections.put(playerId, selection);
}
- selection.points.add(point);
+ selection.addPoint(point);
return selection;
}
@@ -148,10 +42,9 @@ public int maxPointY() {
*/
public @Nullable WandSelection removeLastPoint(@NotNull UUID playerId) {
WandSelection selection = selections.get(playerId);
- if (selection == null || selection.points.isEmpty()) {
+ if (selection == null || !selection.removeLastPoint()) {
return null;
}
- selection.points.remove(selection.points.size() - 1);
return selection;
}
diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/wand/WandSelection.java b/realty-paper/src/main/java/io/github/md5sha256/realty/wand/WandSelection.java
new file mode 100644
index 00000000..f298ccb9
--- /dev/null
+++ b/realty-paper/src/main/java/io/github/md5sha256/realty/wand/WandSelection.java
@@ -0,0 +1,125 @@
+package io.github.md5sha256.realty.wand;
+
+import com.sk89q.worldedit.bukkit.BukkitAdapter;
+import com.sk89q.worldedit.math.BlockVector2;
+import com.sk89q.worldedit.math.BlockVector3;
+import com.sk89q.worldedit.regions.CuboidRegion;
+import com.sk89q.worldedit.regions.Polygonal2DRegion;
+import com.sk89q.worldedit.regions.Region;
+import org.bukkit.World;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A player's in-progress wand selection: an ordered footprint of marked points plus a vertical span
+ * (floor/ceiling) set separately via the height dialog.
+ *
+ *
The footprint shape is inferred from the point count — exactly two points define a rectangle
+ * (opposite corners), three or more define an arbitrary polygon. The height is not taken
+ * from where the player clicked; players mark the footprint on the ground and set the vertical span
+ * afterwards. Mutation is package-private — only {@link SubregionWandManager} edits a selection.
+ */
+public final class WandSelection {
+
+ private final World world;
+ private final List points = new ArrayList<>();
+ private Integer floorY;
+ private Integer ceilingY;
+
+ WandSelection(@NotNull World world) {
+ this.world = world;
+ }
+
+ void addPoint(@NotNull BlockVector3 point) {
+ points.add(point);
+ }
+
+ /** Removes the most recently marked point; returns {@code false} if there was none. */
+ boolean removeLastPoint() {
+ if (points.isEmpty()) {
+ return false;
+ }
+ points.remove(points.size() - 1);
+ return true;
+ }
+
+ public @NotNull World world() {
+ return world;
+ }
+
+ public @NotNull List points() {
+ return List.copyOf(points);
+ }
+
+ public int size() {
+ return points.size();
+ }
+
+ /** Two points make a rectangle; three or more make a polygon. */
+ public boolean isComplete() {
+ return points.size() >= 2;
+ }
+
+ public boolean isPolygon() {
+ return points.size() >= 3;
+ }
+
+ public boolean heightSet() {
+ return floorY != null && ceilingY != null;
+ }
+
+ /** Footprint and height both set — ready to build a region. */
+ public boolean isReady() {
+ return isComplete() && heightSet();
+ }
+
+ public @Nullable Integer floorY() {
+ return floorY;
+ }
+
+ public @Nullable Integer ceilingY() {
+ return ceilingY;
+ }
+
+ public void setHeight(int floor, int ceiling) {
+ this.floorY = Math.min(floor, ceiling);
+ this.ceilingY = Math.max(floor, ceiling);
+ }
+
+ public int minPointY() {
+ int min = Integer.MAX_VALUE;
+ for (BlockVector3 point : points) {
+ min = Math.min(min, point.y());
+ }
+ return min == Integer.MAX_VALUE ? 0 : min;
+ }
+
+ public int maxPointY() {
+ int max = Integer.MIN_VALUE;
+ for (BlockVector3 point : points) {
+ max = Math.max(max, point.y());
+ }
+ return max == Integer.MIN_VALUE ? 0 : max;
+ }
+
+ public @NotNull Region toRegion() {
+ if (!isReady()) {
+ throw new IllegalStateException("Selection is incomplete");
+ }
+ if (points.size() == 2) {
+ BlockVector3 a = points.get(0);
+ BlockVector3 b = points.get(1);
+ BlockVector3 min = BlockVector3.at(Math.min(a.x(), b.x()), floorY, Math.min(a.z(), b.z()));
+ BlockVector3 max = BlockVector3.at(Math.max(a.x(), b.x()), ceilingY, Math.max(a.z(), b.z()));
+ return new CuboidRegion(min, max);
+ }
+ List footprint = new ArrayList<>(points.size());
+ for (BlockVector3 point : points) {
+ footprint.add(BlockVector2.at(point.x(), point.z()));
+ }
+ return new Polygonal2DRegion(BukkitAdapter.adapt(world), footprint, floorY, ceilingY);
+ }
+}