From 7ca3180dbbaac3fa972f7594560b101599b3a902 Mon Sep 17 00:00:00 2001 From: Technofied <40795318+Technofied@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:22:13 +0800 Subject: [PATCH 1/3] refactor: subregion creation to use dialog-assisted prompts --- .../md5sha256/realty/api/RealtyPaperApi.java | 2 +- .../io/github/md5sha256/realty/Realty.java | 17 +- .../realty/api/RealtyPaperApiImpl.java | 4 +- .../realty/command/SubregionCommandGroup.java | 354 ++------- .../realty/command/SubregionDialog.java | 714 ++++++++++++++++++ .../util/SubregionSelectionValidator.java | 165 ++++ .../listener/SubregionWandListener.java | 178 +++++ .../realty/localisation/MessageKeys.java | 9 +- .../md5sha256/realty/settings/Settings.java | 4 + .../md5sha256/realty/wand/SubregionWand.java | 75 ++ .../realty/wand/SubregionWandManager.java | 170 +++++ realty-paper/src/main/resources/messages.yml | 29 +- .../src/main/resources/paper-plugin.yml | 9 +- realty-paper/src/main/resources/settings.yml | 3 + 14 files changed, 1405 insertions(+), 328 deletions(-) create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionDialog.java create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/command/util/SubregionSelectionValidator.java create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/listener/SubregionWandListener.java create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWand.java create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/wand/SubregionWandManager.java diff --git a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java index fcb178c7..0488dcc0 100644 --- a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java +++ b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java @@ -233,7 +233,7 @@ record Error(@NotNull String message) implements QuickCreateSubregionResult {} @NotNull WorldGuardRegion parentRegion, @NotNull String childName, @NotNull Region selection, - double price, long durationSeconds, + double price, long durationSeconds, int maxRenewals, @NotNull UUID landlordId); // --- Sign Place --- diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java index 2b97b63c..b93fbbeb 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/Realty.java @@ -57,6 +57,10 @@ import io.github.md5sha256.realty.database.maria.MariaDatabase; import io.github.md5sha256.realty.listener.PropertyTaxListener; import io.github.md5sha256.realty.listener.SignInteractionListener; +import io.github.md5sha256.realty.listener.SubregionWandListener; +import io.github.md5sha256.realty.command.SubregionDialog; +import io.github.md5sha256.realty.wand.SubregionWand; +import io.github.md5sha256.realty.wand.SubregionWandManager; import io.github.md5sha256.realty.localisation.MessageContainer; import io.github.md5sha256.realty.localisation.MessageKeys; import io.github.md5sha256.realty.settings.ConfigRegionTag; @@ -575,6 +579,16 @@ private void registerCommands( ) { String version = getPluginMeta().getVersion(); var helpCommand = new HelpCommand(messageContainer); + + SubregionWand subregionWand = new SubregionWand(this, this.settings); + SubregionWandManager subregionWandManager = new SubregionWandManager(); + SubregionDialog subregionDialog = new SubregionDialog(paperApi, executorState, + this.database, subregionWandManager, this.settings, this.realtyTags, + messageContainer); + getServer().getPluginManager().registerEvents( + new SubregionWandListener(this, subregionWand, subregionWandManager, + messageContainer), this); + List commands = List.of( new VersionCommand(version), new AddCommand(messageContainer), @@ -614,7 +628,8 @@ private void registerCommands( new RemoveCommand(messageContainer), new SignCommand(paperApi, executorState, messageContainer), new TeleportCommand(getLogger(), paperApi, this.settings, messageContainer, safeLocationFinder), - new SubregionCommandGroup(paperApi, this.settings, messageContainer), + new SubregionCommandGroup(subregionWand, subregionWandManager, subregionDialog, + messageContainer), new CleanupCommandGroup(this.database, executorState, this.realtyTags, diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java index dcfd3283..47070737 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java @@ -857,7 +857,7 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, @NotNull WorldGuardRegion parentRegion, @NotNull String childName, @NotNull Region selection, - double price, long durationSeconds, + double price, long durationSeconds, int maxRenewals, @NotNull UUID landlordId) { String parentId = parentRegion.region().getId(); UUID worldId = parentRegion.world().getUID(); @@ -867,7 +867,7 @@ public RealtyPaperApiImpl(@NotNull RealtyBackend realtyApi, return (QuickCreateSubregionResult) new QuickCreateSubregionResult.NoFreeholdContract(parentId); } boolean created = realtyApi.createLeasehold( - childName, worldId, price, durationSeconds, -1, landlordId); + childName, worldId, price, durationSeconds, maxRenewals, landlordId); if (!created) { return (QuickCreateSubregionResult) new QuickCreateSubregionResult.RegionExists(childName); } diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionCommandGroup.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionCommandGroup.java index 9d077c2a..6df90d7a 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionCommandGroup.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionCommandGroup.java @@ -1,346 +1,86 @@ package io.github.md5sha256.realty.command; -import com.sk89q.worldedit.IncompleteRegionException; -import com.sk89q.worldedit.LocalSession; -import com.sk89q.worldedit.WorldEdit; -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 com.sk89q.worldedit.session.SessionManager; -import com.sk89q.worldguard.WorldGuard; -import com.sk89q.worldguard.protection.managers.RegionManager; -import com.sk89q.worldguard.protection.regions.ProtectedRegion; -import com.sk89q.worldguard.protection.regions.RegionContainer; -import io.github.md5sha256.realty.api.RealtyPaperApi; -import io.github.md5sha256.realty.command.util.DurationParser; -import io.github.md5sha256.realty.command.util.ParseBounds; -import io.github.md5sha256.realty.api.WorldGuardRegion; -import io.github.md5sha256.realty.command.util.WorldGuardRegionParser; import io.github.md5sha256.realty.localisation.MessageContainer; import io.github.md5sha256.realty.localisation.MessageKeys; -import io.github.md5sha256.realty.settings.Settings; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import io.github.md5sha256.realty.wand.SubregionWand; +import io.github.md5sha256.realty.wand.SubregionWandManager; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import org.incendo.cloud.Command; import org.incendo.cloud.context.CommandContext; -import org.incendo.cloud.key.CloudKey; import org.incendo.cloud.paper.util.sender.Source; -import org.incendo.cloud.parser.standard.DoubleParser; -import org.incendo.cloud.parser.standard.StringParser; import org.jetbrains.annotations.NotNull; -import java.time.Duration; -import java.util.Collection; import java.util.List; -import java.util.Objects; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicReference; -import java.util.regex.Pattern; +/** + * Registers the player-facing subregion commands: + * + * + *

Selection geometry, parent auto-detection and validation live in + * {@link SubregionDialog} and {@code SubregionSelectionValidator}.

+ */ public record SubregionCommandGroup( - @NotNull RealtyPaperApi api, - @NotNull AtomicReference settings, + @NotNull SubregionWand wand, + @NotNull SubregionWandManager wandManager, + @NotNull SubregionDialog dialog, @NotNull MessageContainer messages ) implements CustomCommandBean { - private static final CloudKey PARENT_REGION = - CloudKey.of("parentRegion", WorldGuardRegion.class); - private static final CloudKey NAME = CloudKey.of("name", String.class); - private static final CloudKey PRICE = CloudKey.of("price", Double.class); - private static final CloudKey DURATION = CloudKey.of("duration", Duration.class); - private static final Pattern VALID_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9-]+$"); - @Override public @NotNull List> commands( @NotNull Command.Builder builder) { var base = builder.literal("subregion"); return List.of( - base.literal("quickcreate") - .permission("realty.command.subregion.quickcreate") - .required(PARENT_REGION, WorldGuardRegionParser.worldGuardRegion()) - .required(NAME, StringParser.stringParser()) - .required(PRICE, DoubleParser.doubleParser(ParseBounds.MIN_STRICTLY_POSITIVE, - Double.MAX_VALUE)) - .required(DURATION, DurationParser.duration()) - .handler(this::executeQuickCreate) + base.literal("wand") + .permission("realty.command.subregion.wand") + .handler(this::executeWand) + .build(), + base.literal("clear") + .permission("realty.command.subregion.wand") + .handler(this::executeClear) + .build(), + base.literal("confirm") + .permission("realty.command.subregion.confirm") + .handler(this::executeConfirm) .build() ); } - sealed interface SelectionResult { - record Success(@NotNull Region selection) implements SelectionResult {} - record WrongWorld() implements SelectionResult {} - record IncompleteSelection() implements SelectionResult {} - record ExceedsParentBounds() implements SelectionResult {} - record NoRegionManager() implements SelectionResult {} - record OverlapsSibling(@NotNull ProtectedRegion sibling) implements SelectionResult {} - record TooSmall(long volume, int minVolume) implements SelectionResult {} - } - - private static final String BYPASS_PERMISSION = "realty.command.subregion.quickcreate.bypass"; - - private void executeQuickCreate(@NotNull CommandContext ctx) { + private void executeWand(@NotNull CommandContext ctx) { if (!(ctx.sender().source() instanceof Player player)) { ctx.sender().source().sendMessage(messages.messageFor(MessageKeys.COMMON_PLAYERS_ONLY)); return; } - WorldGuardRegion parentRegion = ctx.get(PARENT_REGION); - String name = ctx.get(NAME); - if (!VALID_NAME_PATTERN.matcher(name).matches()) { - player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_INVALID_NAME, - Placeholder.unparsed("region", name))); - return; - } - double price = ctx.get(PRICE); - Duration duration = ctx.get(DURATION); - boolean canBypass = player.hasPermission(BYPASS_PERMISSION); - - RegionContainer regionContainer = WorldGuard.getInstance() - .getPlatform() - .getRegionContainer(); - RegionManager regionManager = regionContainer.get(BukkitAdapter.adapt(parentRegion.world())); - if (regionManager == null) { - player.sendMessage(messages.messageFor(MessageKeys.COMMON_ERROR, - Placeholder.unparsed("error", "Region manager unavailable"))); - return; - } + ItemStack item = wand.createWand(); + player.getInventory().addItem(item).forEach((index, leftover) -> + player.getWorld().dropItem(player.getLocation(), leftover)); + player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_WAND_GIVEN)); + } - if (regionManager.getRegion(name) != null) { - player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_REGION_EXISTS, - Placeholder.unparsed("region", name))); + private void executeClear(@NotNull CommandContext ctx) { + if (!(ctx.sender().source() instanceof Player player)) { + ctx.sender().source().sendMessage(messages.messageFor(MessageKeys.COMMON_PLAYERS_ONLY)); return; } - - Collection blacklist = settings.get().subregionTagBlacklist(); - if (!blacklist.isEmpty()) { - String parentId = parentRegion.region().getId(); - api.getTagIdsByRegion(parentId).thenAccept(tags -> { - for (String tag : tags) { - if (blacklist.contains(tag)) { - player.sendMessage(messages.messageFor( - MessageKeys.SUBREGION_TAG_BLACKLISTED, - Placeholder.unparsed("region", parentId), - Placeholder.unparsed("tag", tag))); - return; - } - } - continueQuickCreate(player, parentRegion, name, price, duration, - canBypass, regionManager); - }).exceptionally(ex -> { - Throwable cause = ex.getCause() != null ? ex.getCause() : ex; - cause.printStackTrace(); - player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_CREATE_ERROR, - Placeholder.unparsed("error", cause.getMessage()))); - return null; - }); + if (wandManager.get(player.getUniqueId()) == null) { + player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_NOTHING_TO_CLEAR)); return; } - - continueQuickCreate(player, parentRegion, name, price, duration, - canBypass, regionManager); - } - - private void continueQuickCreate(@NotNull Player player, - @NotNull WorldGuardRegion parentRegion, - @NotNull String name, - double price, - @NotNull Duration duration, - boolean canBypass, - @NotNull RegionManager regionManager) { - SelectionResult result = validateSelection(player, parentRegion, regionManager, - settings.get().subregionMinVolume()); - switch (result) { - case SelectionResult.WrongWorld ignored -> - player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_WRONG_WORLD)); - case SelectionResult.IncompleteSelection ignored -> - player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_INCOMPLETE_SELECTION)); - case SelectionResult.ExceedsParentBounds ignored -> - player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_EXCEEDS_BOUNDS, - Placeholder.unparsed("region", parentRegion.region().getId()))); - case SelectionResult.NoRegionManager ignored -> - player.sendMessage(messages.messageFor(MessageKeys.COMMON_ERROR, - Placeholder.unparsed("error", "Region manager unavailable"))); - case SelectionResult.OverlapsSibling overlap -> - player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_OVERLAPS_SIBLING, - Placeholder.unparsed("sibling", overlap.sibling().getId()))); - case SelectionResult.TooSmall tooSmall -> - player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_TOO_SMALL, - Placeholder.unparsed("volume", String.valueOf(tooSmall.volume())), - Placeholder.unparsed("min-volume", String.valueOf(tooSmall.minVolume())))); - case SelectionResult.Success success -> { - UUID playerId = player.getUniqueId(); - String parentId = parentRegion.region().getId(); - if (!canBypass && !parentRegion.region().getOwners().contains(playerId)) { - player.sendMessage(messages.messageFor( - MessageKeys.SUBREGION_NOT_TITLEHOLDER, - Placeholder.unparsed("region", parentId))); - return; - } - api.quickCreateSubregion(parentRegion, name, success.selection(), - price, duration.toSeconds(), playerId) - .thenAccept(qcResult -> { - switch (qcResult) { - case RealtyPaperApi.QuickCreateSubregionResult.Success s -> - 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; - cause.printStackTrace(); - player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_CREATE_ERROR, - Placeholder.unparsed("error", cause.getMessage()))); - return null; - }); - } - } - } - - private static @NotNull SelectionResult validateSelection(@NotNull Player player, - @NotNull WorldGuardRegion parentRegion, - @NotNull RegionManager regionManager, - int minVolume) { - SessionManager sessionManager = WorldEdit.getInstance().getSessionManager(); - LocalSession localSession = sessionManager.get(BukkitAdapter.adapt(player)); - if (!Objects.equals(localSession.getSelectionWorld(), - BukkitAdapter.adapt(parentRegion.world()))) { - return new SelectionResult.WrongWorld(); - } - Region selection; - try { - selection = localSession.getSelection().clone(); - } catch (IncompleteRegionException ex) { - return new SelectionResult.IncompleteSelection(); - } - long volume = selection.getVolume(); - if (volume < minVolume) { - return new SelectionResult.TooSmall(volume, minVolume); - } - ProtectedRegion parent = parentRegion.region(); - if (!regionIsFullyContainedByParent(selection, parent)) { - return new SelectionResult.ExceedsParentBounds(); - } - for (ProtectedRegion sibling : regionManager.getRegions().values()) { - if (!Objects.equals(sibling.getParent(), parent)) { - continue; - } - for (BlockVector3 point : selection) { - if (sibling.contains(point)) { - return new SelectionResult.OverlapsSibling(sibling); - } - } - } - return new SelectionResult.Success(selection); - } - - private 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; + wandManager.clear(player.getUniqueId()); + player.sendMessage(messages.messageFor(MessageKeys.SUBREGION_SELECTION_CLEARED)); } - 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; - } - } + private void executeConfirm(@NotNull CommandContext ctx) { + if (!(ctx.sender().source() instanceof Player player)) { + ctx.sender().source().sendMessage(messages.messageFor(MessageKeys.COMMON_PLAYERS_ONLY)); + return; } - return true; + dialog.openHeight(player); } - } 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 new file mode 100644 index 00000000..fa5694b3 --- /dev/null +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SubregionDialog.java @@ -0,0 +1,714 @@ +package io.github.md5sha256.realty.command; + +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.regions.Region; +import com.sk89q.worldguard.WorldGuard; +import com.sk89q.worldguard.protection.managers.RegionManager; +import com.sk89q.worldguard.protection.regions.ProtectedRegion; +import io.github.md5sha256.realty.api.CurrencyFormatter; +import io.github.md5sha256.realty.api.ExecutorState; +import io.github.md5sha256.realty.api.RealtyPaperApi; +import io.github.md5sha256.realty.api.WorldGuardRegion; +import io.github.md5sha256.realty.command.util.SubregionSelectionValidator; +import io.github.md5sha256.realty.database.Database; +import io.github.md5sha256.realty.database.SqlSessionWrapper; +import io.github.md5sha256.realty.database.entity.FreeholdContractEntity; +import io.github.md5sha256.realty.database.mapper.RegionTagMapper; +import io.github.md5sha256.realty.localisation.MessageContainer; +import io.github.md5sha256.realty.localisation.MessageKeys; +import io.github.md5sha256.realty.settings.ConfigRegionTag; +import io.github.md5sha256.realty.settings.RealtyTags; +import io.github.md5sha256.realty.settings.Settings; +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.dialog.DialogResponseView; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.action.DialogActionCallback; +import io.papermc.paper.registry.data.dialog.body.DialogBody; +import io.papermc.paper.registry.data.dialog.input.DialogInput; +import io.papermc.paper.registry.data.dialog.input.SingleOptionDialogInput; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; + +import io.github.md5sha256.realty.wand.SubregionWandManager; + +/** + * Guided two-page dialog for creating a subregion from the player's wand selection. + * + *

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); + } +}