diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/SearchMapper.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/SearchMapper.java index 56541dd..8b9e637 100644 --- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/SearchMapper.java +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/SearchMapper.java @@ -8,10 +8,11 @@ import java.util.List; /** - * Mapper for searching regions by contract type, tags, and price range. + * Mapper for searching regions by contract type, tags, price range, and lease occupancy. * * @param tagIds when non-null, only regions with at least one of these tags are included * @param excludedTagIds when non-null, regions with any of these tags are excluded + * @param excludeRented when true, occupied leaseholds are omitted from results * @see SearchResultEntity */ public interface SearchMapper { @@ -20,6 +21,7 @@ public interface SearchMapper { boolean includeLeasehold, @Nullable Collection tagIds, @Nullable Collection excludedTagIds, + boolean excludeRented, double minPrice, double maxPrice, int limit, @@ -29,6 +31,7 @@ int searchCount(boolean includeFreehold, boolean includeLeasehold, @Nullable Collection tagIds, @Nullable Collection excludedTagIds, + boolean excludeRented, double minPrice, double maxPrice); diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaSearchMapper.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaSearchMapper.java index 62b066f..732db7b 100644 --- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaSearchMapper.java +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaSearchMapper.java @@ -58,6 +58,9 @@ AND NOT EXISTS ( INNER JOIN LeaseholdContract lc ON lc.leaseholdContractId = c.contractId WHERE lc.price >= #{minPrice} AND lc.price <= #{maxPrice} + + AND lc.tenantId IS NULL + AND EXISTS ( SELECT 1 FROM RegionTag rt @@ -94,6 +97,7 @@ AND NOT EXISTS ( @Param("includeLeasehold") boolean includeLeasehold, @Param("tagIds") @Nullable Collection tagIds, @Param("excludedTagIds") @Nullable Collection excludedTagIds, + @Param("excludeRented") boolean excludeRented, @Param("minPrice") double minPrice, @Param("maxPrice") double maxPrice, @Param("limit") int limit, @@ -142,6 +146,9 @@ AND NOT EXISTS ( INNER JOIN LeaseholdContract lc ON lc.leaseholdContractId = c.contractId WHERE lc.price >= #{minPrice} AND lc.price <= #{maxPrice} + + AND lc.tenantId IS NULL + AND EXISTS ( SELECT 1 FROM RegionTag rt @@ -170,6 +177,7 @@ int searchCount(@Param("includeFreehold") boolean includeFreehold, @Param("includeLeasehold") boolean includeLeasehold, @Param("tagIds") @Nullable Collection tagIds, @Param("excludedTagIds") @Nullable Collection excludedTagIds, + @Param("excludeRented") boolean excludeRented, @Param("minPrice") double minPrice, @Param("maxPrice") double maxPrice); diff --git a/realty-backend/src/test/java/io/github/md5sha256/realty/database/MapperTest.java b/realty-backend/src/test/java/io/github/md5sha256/realty/database/MapperTest.java index f904404..0d19605 100644 --- a/realty-backend/src/test/java/io/github/md5sha256/realty/database/MapperTest.java +++ b/realty-backend/src/test/java/io/github/md5sha256/realty/database/MapperTest.java @@ -5,6 +5,7 @@ import io.github.md5sha256.realty.database.entity.LeaseholdContractEntity; import io.github.md5sha256.realty.database.entity.OutboundOfferView; import io.github.md5sha256.realty.database.entity.RealtyRegionEntity; +import io.github.md5sha256.realty.database.entity.SearchResultEntity; import io.github.md5sha256.realty.database.entity.FreeholdContractAuctionEntity; import io.github.md5sha256.realty.database.entity.FreeholdContractBid; import io.github.md5sha256.realty.database.entity.FreeholdContractBidPaymentEntity; @@ -228,6 +229,69 @@ void countByTenant() { } } + // ==================== SearchMapper ==================== + + @Nested + @DisplayName("SearchMapper") + class SearchMapperTests { + + @Test + @DisplayName("excludeRented omits occupied leaseholds from search results") + void excludeRentedFiltersOccupiedLeaseholds() { + String availableLeasehold = uniqueRegionId(); + createLeaseholdRegion(availableLeasehold, AUTHORITY); + + String rentedLeasehold = uniqueRegionId(); + createLeaseholdRegion(rentedLeasehold, AUTHORITY); + logic.rentRegion(rentedLeasehold, WORLD_ID, PLAYER_A); + + try (SqlSessionWrapper wrapper = database.openSession()) { + int countIncludingRented = wrapper.searchMapper() + .searchCount(false, true, null, null, + false, 0.0, Double.MAX_VALUE); + int countExcludingRented = wrapper.searchMapper() + .searchCount(false, true, null, null, + true, 0.0, Double.MAX_VALUE); + List availableOnly = wrapper.searchMapper() + .search(false, true, null, null, + true, 0.0, Double.MAX_VALUE, 10, 0); + + Assertions.assertEquals(2, countIncludingRented); + Assertions.assertEquals(1, countExcludingRented); + Assertions.assertEquals(1, availableOnly.size()); + Assertions.assertEquals(availableLeasehold, availableOnly.get(0).worldGuardRegionId()); + } + } + + @Test + @DisplayName("excludeRented does not hide freeholds") + void excludeRentedKeepsFreeholds() { + String freehold = uniqueRegionId(); + boolean created = logic.createFreehold(freehold, WORLD_ID, 1000.0, AUTHORITY, null); + Assertions.assertTrue(created); + + String availableLeasehold = uniqueRegionId(); + createLeaseholdRegion(availableLeasehold, AUTHORITY); + + String rentedLeasehold = uniqueRegionId(); + createLeaseholdRegion(rentedLeasehold, AUTHORITY); + logic.rentRegion(rentedLeasehold, WORLD_ID, PLAYER_A); + + try (SqlSessionWrapper wrapper = database.openSession()) { + List results = wrapper.searchMapper() + .search(true, true, null, null, + true, 0.0, Double.MAX_VALUE, 10, 0); + List regionIds = results.stream() + .map(SearchResultEntity::worldGuardRegionId) + .toList(); + + Assertions.assertTrue(regionIds.contains(freehold)); + Assertions.assertTrue(regionIds.contains(availableLeasehold)); + Assertions.assertFalse(regionIds.contains(rentedLeasehold)); + } + } + } + // ==================== FreeholdContractMapper ==================== @Nested diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/SearchCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SearchCommand.java index 622aa4b..2137cde 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/SearchCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SearchCommand.java @@ -47,6 +47,10 @@ public record SearchCommand( .withComponent(StringParser.stringParser()) .build(); + private static final CommandFlag EXCLUDE_RENTED_FLAG = + CommandFlag.builder("exclude-rented") + .build(); + private static final CommandFlag MIN_PRICE_FLAG = CommandFlag.builder("min-price") .withComponent(DoubleParser.doubleParser(0)) @@ -77,6 +81,7 @@ public record SearchCommand( .flag(LEASEHOLD_FLAG) .flag(TAGS_FLAG) .flag(EXCLUDE_TAGS_FLAG) + .flag(EXCLUDE_RENTED_FLAG) .flag(MIN_PRICE_FLAG) .flag(MAX_PRICE_FLAG) .flag(PAGE_FLAG) @@ -104,12 +109,13 @@ private void executeResults(@NotNull CommandContext ctx) { } Collection tagIds = parseTagIds(ctx.flags().getValue(TAGS_FLAG, null)); Collection excludedTagIds = parseTagIds(ctx.flags().getValue(EXCLUDE_TAGS_FLAG, null)); + boolean excludeRented = ctx.flags().hasFlag(EXCLUDE_RENTED_FLAG); double minPrice = ctx.flags().getValue(MIN_PRICE_FLAG, 0.0); double maxPrice = ctx.flags().getValue(MAX_PRICE_FLAG, Double.MAX_VALUE); int page = ctx.flags().getValue(PAGE_FLAG, 1); searchDialog.performSearch(sender, includeFreehold, includeLeasehold, tagIds, - excludedTagIds, minPrice, maxPrice, page); + excludedTagIds, excludeRented, minPrice, maxPrice, page); } @Nullable diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/SearchDialog.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SearchDialog.java index d9b73e9..d90fdb7 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/SearchDialog.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SearchDialog.java @@ -59,6 +59,7 @@ public final class SearchDialog { static final String INPUT_FREEHOLD = "freehold"; static final String INPUT_LEASEHOLD = "leasehold"; + static final String INPUT_EXCLUDE_RENTED = "exclude_rented"; static final String INPUT_MIN_PRICE = "min_price"; static final String INPUT_MAX_PRICE = "max_price"; @@ -85,6 +86,7 @@ TagState next() { static final class SearchState { boolean freehold = true; boolean leasehold = true; + boolean excludeRented = false; String minPrice = "0"; String maxPrice = ""; final Map tagStates = new LinkedHashMap<>(); @@ -129,6 +131,11 @@ private void showMainDialog(@NotNull Player player, .onTrue("true") .onFalse("false") .build()); + inputs.add(DialogInput.bool(INPUT_EXCLUDE_RENTED, Component.text("Exclude Rented Leaseholds")) + .initial(state.excludeRented) + .onTrue("true") + .onFalse("false") + .build()); inputs.add(DialogInput.text(INPUT_MIN_PRICE, Component.text("Min Price")) .width(150) .initial(state.minPrice) @@ -161,6 +168,7 @@ private void showMainDialog(@NotNull Player player, double maxPrice = parsePrice(state.maxPrice, Double.MAX_VALUE); boolean includeFreehold = state.freehold; boolean includeLeasehold = state.leasehold; + boolean excludeRented = state.excludeRented; playerStates.remove(player.getUniqueId()); if (!includeFreehold && !includeLeasehold) { @@ -168,7 +176,7 @@ private void showMainDialog(@NotNull Player player, return; } performSearch(audience, includeFreehold, includeLeasehold, tagFilter, - excludeFilter, minPrice, maxPrice, 1); + excludeFilter, excludeRented, minPrice, maxPrice, 1); }; DialogActionCallback configTagsCallback = (response, audience) -> { @@ -193,7 +201,7 @@ private void showMainDialog(@NotNull Player player, .canCloseWithEscape(true) .afterAction(DialogBase.DialogAfterAction.CLOSE) .body(List.of(DialogBody.plainMessage( - Component.text("Filter regions by type and price range.")))) + Component.text("Filter regions by type, occupancy, and price range.")))) .inputs(inputs) .build()) .type(DialogType.multiAction( @@ -280,6 +288,10 @@ private void saveCriteria(@NotNull SearchState state, state.freehold = freehold == null || freehold; Boolean leasehold = response.getBoolean(INPUT_LEASEHOLD); state.leasehold = leasehold == null || leasehold; + Boolean excludeRented = response.getBoolean(INPUT_EXCLUDE_RENTED); + if (excludeRented != null) { + state.excludeRented = excludeRented; + } String minPrice = response.getText(INPUT_MIN_PRICE); if (minPrice != null) { state.minPrice = minPrice; @@ -297,12 +309,13 @@ void performSearch(@NotNull Audience sender, boolean includeFreehold, boolean includeLeasehold, @Nullable Collection tagIds, @Nullable Collection excludedTagIds, + boolean excludeRented, double minPrice, double maxPrice, int page) { CompletableFuture.runAsync(() -> { try (SqlSessionWrapper session = database.openSession(true)) { SearchMapper mapper = session.searchMapper(); int totalCount = mapper.searchCount(includeFreehold, includeLeasehold, - tagIds, excludedTagIds, minPrice, maxPrice); + tagIds, excludedTagIds, excludeRented, minPrice, maxPrice); if (totalCount == 0) { sender.sendMessage(messages.messageFor(MessageKeys.SEARCH_NO_RESULTS)); @@ -319,7 +332,7 @@ void performSearch(@NotNull Audience sender, int offset = (page - 1) * PAGE_SIZE; List results = mapper.search(includeFreehold, includeLeasehold, - tagIds, excludedTagIds, minPrice, maxPrice, PAGE_SIZE, offset); + tagIds, excludedTagIds, excludeRented, minPrice, maxPrice, PAGE_SIZE, offset); TextComponent.Builder builder = Component.text(); builder.append(messages.messageFor(MessageKeys.SEARCH_HEADER, @@ -336,7 +349,7 @@ void performSearch(@NotNull Audience sender, } appendFooter(builder, includeFreehold, includeLeasehold, tagIds, excludedTagIds, - minPrice, maxPrice, page, totalPages); + excludeRented, minPrice, maxPrice, page, totalPages); sender.sendMessage(builder.build()); } catch (Exception ex) { sender.sendMessage(messages.messageFor(MessageKeys.SEARCH_ERROR, @@ -361,15 +374,16 @@ private void appendFooter(@NotNull TextComponent.Builder builder, boolean includeFreehold, boolean includeLeasehold, @Nullable Collection tagIds, @Nullable Collection excludedTagIds, + boolean excludeRented, double minPrice, double maxPrice, int page, int totalPages) { Component previousComponent = page > 1 ? buildNavComponent(MessageKeys.SEARCH_PREVIOUS, includeFreehold, includeLeasehold, - tagIds, excludedTagIds, minPrice, maxPrice, page - 1) + tagIds, excludedTagIds, excludeRented, minPrice, maxPrice, page - 1) : Component.empty(); Component nextComponent = page < totalPages ? buildNavComponent(MessageKeys.SEARCH_NEXT, includeFreehold, includeLeasehold, - tagIds, excludedTagIds, minPrice, maxPrice, page + 1) + tagIds, excludedTagIds, excludeRented, minPrice, maxPrice, page + 1) : Component.empty(); builder.appendNewline() .append(messages.messageFor(MessageKeys.SEARCH_FOOTER, @@ -383,8 +397,21 @@ private void appendFooter(@NotNull TextComponent.Builder builder, boolean includeFreehold, boolean includeLeasehold, @Nullable Collection tagIds, @Nullable Collection excludedTagIds, + boolean excludeRented, double minPrice, double maxPrice, int targetPage) { + return parseMiniMessage(key, "", buildResultsCommand(includeFreehold, includeLeasehold, + tagIds, excludedTagIds, excludeRented, minPrice, maxPrice, targetPage)); + } + + static @NotNull String buildResultsCommand(boolean includeFreehold, + boolean includeLeasehold, + @Nullable Collection tagIds, + @Nullable Collection excludedTagIds, + boolean excludeRented, + double minPrice, + double maxPrice, + int targetPage) { StringBuilder command = new StringBuilder("/realty search results"); if (includeFreehold) { command.append(" --freehold"); @@ -398,6 +425,9 @@ private void appendFooter(@NotNull TextComponent.Builder builder, if (excludedTagIds != null && !excludedTagIds.isEmpty()) { command.append(" --exclude-tags ").append(String.join(",", excludedTagIds)); } + if (excludeRented) { + command.append(" --exclude-rented"); + } if (minPrice > 0) { command.append(" --min-price ").append(minPrice); } @@ -405,7 +435,7 @@ private void appendFooter(@NotNull TextComponent.Builder builder, command.append(" --max-price ").append(maxPrice); } command.append(" --page ").append(targetPage); - return parseMiniMessage(key, "", command.toString()); + return command.toString(); } private @NotNull Component parseMiniMessage(@NotNull String key, diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/command/SearchDialogTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/command/SearchDialogTest.java new file mode 100644 index 0000000..827ab5b --- /dev/null +++ b/realty-paper/src/test/java/io/github/md5sha256/realty/command/SearchDialogTest.java @@ -0,0 +1,42 @@ +package io.github.md5sha256.realty.command; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +class SearchDialogTest { + + @Nested + @DisplayName("buildResultsCommand") + class BuildResultsCommand { + + @Test + @DisplayName("includes exclude-rented flag when requested") + void includesExcludeRentedFlag() { + String command = SearchDialog.buildResultsCommand( + true, true, List.of("farm", "port"), List.of("vip"), + true, 25.0, 250.0, 3); + + Assertions.assertTrue(command.contains(" --exclude-rented")); + Assertions.assertTrue(command.contains(" --freehold")); + Assertions.assertTrue(command.contains(" --leasehold")); + Assertions.assertTrue(command.contains(" --tags farm,port")); + Assertions.assertTrue(command.contains(" --exclude-tags vip")); + Assertions.assertTrue(command.endsWith(" --page 3")); + } + + @Test + @DisplayName("omits exclude-rented flag by default") + void omitsExcludeRentedFlag() { + String command = SearchDialog.buildResultsCommand( + false, true, null, null, + false, 0.0, Double.MAX_VALUE, 1); + + Assertions.assertFalse(command.contains(" --exclude-rented")); + Assertions.assertEquals("/realty search results --leasehold --page 1", command); + } + } +}