Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,4 @@ buildNumber.properties

# Common working directory
run/
docs/superpowers/
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
## Snapshot 2.0.8.1

# Ideas
- ability to search enchantment type if its an enchantment book

### What's new for server owners
- **In-GUI sort and filter controls** — players can now sort and filter search results directly from the shop search GUI without re-running the command. A sort button (HOPPER, slot 47) cycles through Default → Price ↑ → Price ↓ → Distance ↑. Three filter toggle buttons (slots 48, 50, 51) let players show only shops with available stock/space, restrict results to their current world, or hide their own shops. Preferences are saved per-player and persist across server restarts (`player_prefs.json`).
- **Noticeably faster searches on large servers** — the shop list sync that runs every 15 minutes (and on startup) has been rewritten to scale linearly instead of getting slower the more shops your server has. Servers with thousands of shops will see a significant drop in that sync time.
- **Search results now always respect your configured sorting method** — previously, browsing all shops (`/finditem TO_BUY *`) ignored your `shop-sorting-method` setting and always sorted by a fixed method. It now behaves the same as all other searches.
- **Snappier GUI clicks** — shop teleportation and custom command execution on GUI click are now faster under the hood, with less redundant work per click.
- **Java 25 & Paper API 26.x support** — ready for the latest server versions out of the box.
- **New: limit search results by distance** ([#107](https://github.com/myzticbean/QSFindItemAddOn/issues/107)): a new `shop-search-max-distance` config option lets you hide shops that are too far away from the searching player. Set it to a block radius (e.g. `1000`) to keep results local. Disabled by default (set to `0`).

### Changes (technical)
- Added `SortMode` enum (`DEFAULT`, `PRICE_ASC`, `PRICE_DESC`, `DISTANCE_ASC`) with `next()` cycle method
- Added `PlayerSortFilterPrefs` model (Gson-serializable POJO) holding sort mode and three filter booleans
- Added `PlayerPrefsStorageUtil`: `ConcurrentHashMap`-backed per-player prefs store, async saves via `VirtualThreadScheduler`, persists to `player_prefs.json` in plugin data folder; loaded in `runPluginStartupTasks`, flushed synchronously in `onDisable`
- `FoundShopsMenu`: sort/filter applied lazily via Java stream inside `setMenuItems()` — raw result list is never copied or modified; short-circuits to zero overhead when no preferences are active; cross-world shops sorted to end for distance mode using `distanceSquared` (no sqrt)
- `PaginatedMenu`: freed nav bar slots 47/48/50/51 (previously idle fillers) for `FoundShopsMenu` to populate with dynamic sort/filter buttons
- Upgraded to Paper API 26.x / Java 25
- Added `PlatformBridge` abstraction (`BukkitPlatformBridge`) to decouple platform-specific calls from core logic
- Refactored `QSHikariAPIHandler`: consolidated three duplicate shop-search methods into a single `searchShops(Predicate, boolean, Player)` method, removing ~70 lines of duplication
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Shop Search AddOn For QuickShop
### Version: 2.0.8.1-SNAPSHOT-260517
### Version: 2.0.8.1-SNAPSHOT-260520

An unofficial add-on for the QuickShop Hikari and Reremake spigot plugin.
Adds a `/finditem` command in game for searching through all the shops on the server.
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.myzticbean.finditemaddon</groupId>
<artifactId>QSFindItemAddOn</artifactId>
<version>2.0.8.1-SNAPSHOT-260517</version>
<version>2.0.8.1-SNAPSHOT-260520</version>
<packaging>jar</packaging>

<name>QSFindItemAddOn</name>
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/io/myzticbean/finditemaddon/FindItemAddOn.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import io.myzticbean.finditemaddon.scheduledtasks.Task15MinInterval;
import io.myzticbean.finditemaddon.utils.UpdateChecker;
import io.myzticbean.finditemaddon.utils.async.VirtualThreadScheduler;
import io.myzticbean.finditemaddon.utils.json.PlayerPrefsStorageUtil;
import io.myzticbean.finditemaddon.utils.json.ShopSearchActivityStorageUtil;
import io.myzticbean.finditemaddon.utils.log.Logger;
import io.myzticbean.finditemaddon.utils.platform.BukkitPlatformBridge;
Expand Down Expand Up @@ -182,6 +183,7 @@ public void onDisable() {
// Plugin shutdown logic
if(qsApi != null) {
ShopSearchActivityStorageUtil.saveShopsToFile();
PlayerPrefsStorageUtil.save();
}
else if(!ENABLE_TRIAL_PERIOD) {
Logger.logError("Uh oh! Looks like either this plugin has crashed or you don't have QuickShop-Hikari installed.");
Expand Down Expand Up @@ -214,6 +216,7 @@ private void runPluginStartupTasks() {

// Load all hidden shops from file
ShopSearchActivityStorageUtil.loadShopsFromFile();
PlayerPrefsStorageUtil.load();

// v2.0.0.0 - Migrating hiddenShops.json to shops.json
ShopSearchActivityStorageUtil.migrateHiddenShopsToShopsJson();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,8 @@ public void addMenuBottomBar() {
inventory.setItem(52, lastPageButton);
inventory.setItem(49, closeInvButton);

inventory.setItem(47, super.GUI_FILLER_ITEM);
inventory.setItem(48, super.GUI_FILLER_ITEM);
inventory.setItem(50, super.GUI_FILLER_ITEM);
inventory.setItem(51, super.GUI_FILLER_ITEM);
// Slots 47, 48, 50, 51 are intentionally left empty here.
// FoundShopsMenu fills them with sort/filter control buttons.
}

private void createGUIBackButton() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@
import io.myzticbean.finditemaddon.handlers.gui.PaginatedMenu;
import io.myzticbean.finditemaddon.handlers.gui.PlayerMenuUtility;
import io.myzticbean.finditemaddon.models.FoundShopItemModel;
import io.myzticbean.finditemaddon.models.PlayerSortFilterPrefs;
import io.myzticbean.finditemaddon.models.enums.CustomCmdPlaceholdersEnum;
import io.myzticbean.finditemaddon.models.enums.PlayerPermsEnum;
import io.myzticbean.finditemaddon.models.enums.ShopLorePlaceholdersEnum;
import io.myzticbean.finditemaddon.models.enums.SortMode;
import io.myzticbean.finditemaddon.utils.json.PlayerPrefsStorageUtil;
import io.myzticbean.finditemaddon.utils.PlayerUtil;
import io.myzticbean.finditemaddon.utils.async.VirtualThreadScheduler;
import io.myzticbean.finditemaddon.utils.json.ShopSearchActivityStorageUtil;
Expand Down Expand Up @@ -59,9 +62,11 @@

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Stream;

/**
* Handler class for FoundShops GUI
Expand Down Expand Up @@ -128,6 +133,7 @@ public void handleMenu(@NotNull InventoryClickEvent event) {
* @return true if a navigation button was clicked, false otherwise
*/
private boolean handleNavigationClick(InventoryClickEvent event, int slot) {
Player player = (Player) event.getWhoClicked();
return switch (slot) {
case 45 -> {
handleMenuClickForNavToPrevPage(event);
Expand All @@ -137,6 +143,42 @@ private boolean handleNavigationClick(InventoryClickEvent event, int slot) {
handleFirstPageClick(event);
yield true;
}
case 47 -> {
// Sort cycle: DEFAULT → PRICE_ASC → PRICE_DESC → DISTANCE_ASC → DEFAULT
PlayerSortFilterPrefs prefs47 = PlayerPrefsStorageUtil.getPrefs(player.getUniqueId());
prefs47.setSortMode(prefs47.getSortMode().next());
PlayerPrefsStorageUtil.saveAsync();
page = 0;
super.open(super.playerMenuUtility.getPlayerShopSearchResult());
yield true;
}
case 48 -> {
// Filter: Has Stock/Space toggle
PlayerSortFilterPrefs prefs48 = PlayerPrefsStorageUtil.getPrefs(player.getUniqueId());
prefs48.setFilterHasStock(!prefs48.isFilterHasStock());
PlayerPrefsStorageUtil.saveAsync();
page = 0;
super.open(super.playerMenuUtility.getPlayerShopSearchResult());
yield true;
}
case 50 -> {
// Filter: Same World Only toggle
PlayerSortFilterPrefs prefs50 = PlayerPrefsStorageUtil.getPrefs(player.getUniqueId());
prefs50.setFilterSameWorld(!prefs50.isFilterSameWorld());
PlayerPrefsStorageUtil.saveAsync();
page = 0;
super.open(super.playerMenuUtility.getPlayerShopSearchResult());
yield true;
}
case 51 -> {
// Filter: Exclude Own Shops toggle
PlayerSortFilterPrefs prefs51 = PlayerPrefsStorageUtil.getPrefs(player.getUniqueId());
prefs51.setFilterExcludeOwnShops(!prefs51.isFilterExcludeOwnShops());
PlayerPrefsStorageUtil.saveAsync();
page = 0;
super.open(super.playerMenuUtility.getPlayerShopSearchResult());
yield true;
}
case 52 -> {
handleLastPageClick(event);
yield true;
Expand Down Expand Up @@ -425,35 +467,160 @@ private void handleLastPageClick(InventoryClickEvent event) {
public void setMenuItems(List<FoundShopItemModel> foundShops) {
// Add the bottom navigation bar to the menu
addMenuBottomBar();
// Populate sort/filter buttons in slots 47, 48, 50, 51
setSortFilterButtons();

// If no shops were found, return early
if (foundShops == null || foundShops.isEmpty()) {
return;
}

// Apply player's sort/filter preferences lazily — raw list is never modified
List<FoundShopItemModel> viewList = applyPrefs(foundShops);

int maxItemsPerPage = MAX_ITEMS_PER_PAGE;
// Iterate through the slots for this page
for (int guiSlotCounter = 0; guiSlotCounter < maxItemsPerPage; guiSlotCounter++) {
// Calculate the index in the foundShops list for the current slot
// Calculate the index in the view list for the current slot
index = maxItemsPerPage * page + guiSlotCounter;
if (index >= foundShops.size()) {
if (index >= viewList.size()) {
break;
}

FoundShopItemModel foundShop = foundShops.get(index);
if (foundShop == null) {
continue;
}
FoundShopItemModel foundShop = viewList.get(index);

// Create an ItemStack for the shop and add it to the inventory
ItemStack item = createShopItem(foundShop);
inventory.addItem(item);
}
}

/**
* Applies the player's sort/filter preferences to the raw shop list.
* Returns the raw list directly when no preferences are active (zero-overhead path).
* The raw list is never modified; the returned list is a fresh view computed on demand.
* Runs inside runAtEntity (main/region thread), so player.getLocation() is safe.
*/
private List<FoundShopItemModel> applyPrefs(List<FoundShopItemModel> raw) {
Player player = playerMenuUtility.getOwner();
if (player == null) return raw;

PlayerSortFilterPrefs prefs = PlayerPrefsStorageUtil.getPrefs(player.getUniqueId());

// Short-circuit: nothing to do — return raw directly with zero allocation
if (prefs.getSortMode() == SortMode.DEFAULT
&& !prefs.isFilterHasStock()
&& !prefs.isFilterSameWorld()
&& !prefs.isFilterExcludeOwnShops()) {
return raw;
}

// Filter nulls first — mirrors the original null-guard in the loop above
Stream<FoundShopItemModel> stream = raw.stream().filter(Objects::nonNull);

// --- Filters (cheap predicates, applied in ascending cost order) ---

if (prefs.isFilterHasStock()) {
// Keep shops with actual stock/space.
// Sentinel values: -1 and MAX_VALUE mean unlimited (always shown).
// -2 means unknown (cache miss) — excluded when filter is ON to avoid showing stale data.
stream = stream.filter(s -> {
int v = s.getRemainingStockOrSpace();
return v > 0 || v == -1 || v == Integer.MAX_VALUE;
});
}

if (prefs.isFilterSameWorld()) {
World playerWorld = player.getWorld();
stream = stream.filter(s -> playerWorld.equals(s.getShopLocation().getWorld()));
}

if (prefs.isFilterExcludeOwnShops()) {
UUID playerUUID = player.getUniqueId();
stream = stream.filter(s -> !s.getShopOwner().equals(playerUUID));
}

// --- Sort (at most one comparator applied) ---

Comparator<FoundShopItemModel> comparator = switch (prefs.getSortMode()) {
case PRICE_ASC -> Comparator.comparingDouble(FoundShopItemModel::getShopPrice);
case PRICE_DESC -> Comparator.comparingDouble(FoundShopItemModel::getShopPrice).reversed();
case DISTANCE_ASC -> {
// Use distanceSquared (no sqrt) for performance.
// Cross-world shops have no meaningful distance — push them to the end.
Location playerLoc = player.getLocation();
yield Comparator.comparingDouble((FoundShopItemModel s) -> {
if (!playerLoc.getWorld().equals(s.getShopLocation().getWorld())) {
return Double.MAX_VALUE;
}
return playerLoc.distanceSquared(s.getShopLocation());
});
}
default -> null;
};

if (comparator != null) {
stream = stream.sorted(comparator);
}

return stream.toList();
}

/**
* Places the sort and filter control buttons into nav bar slots 47, 48, 50, 51.
* Called from setMenuItems() after addMenuBottomBar() has populated the other slots.
*/
private void setSortFilterButtons() {
Player player = playerMenuUtility.getOwner();
if (player == null) return;
PlayerSortFilterPrefs prefs = PlayerPrefsStorageUtil.getPrefs(player.getUniqueId());

inventory.setItem(47, buildSortButton(prefs.getSortMode()));
inventory.setItem(48, buildFilterButton("Has Stock/Space", prefs.isFilterHasStock()));
inventory.setItem(50, buildFilterButton("Same World Only", prefs.isFilterSameWorld()));
inventory.setItem(51, buildFilterButton("Exclude Own Shops", prefs.isFilterExcludeOwnShops()));
}

/**
* Builds the sort cycle button for slot 47.
* Display name reflects the currently active sort mode.
*/
private ItemStack buildSortButton(SortMode mode) {
String label = switch (mode) {
case PRICE_ASC -> "&aSort: Price ↑";
case PRICE_DESC -> "&aSort: Price ↓";
case DISTANCE_ASC -> "&aSort: Distance ↑";
default -> "&7Sort: Default";
};
ItemStack item = new ItemStack(Material.HOPPER);
ItemMeta meta = item.getItemMeta();
if (meta == null) return item;
meta.setDisplayName(ColorTranslator.translateColorCodes(label));
meta.setLore(List.of(ColorTranslator.translateColorCodes("&7Click to cycle sort mode")));
item.setItemMeta(meta);
return item;
}

/**
* Builds a filter toggle button for slots 48, 50, or 51.
*
* @param label Human-readable filter name shown in the display name.
* @param active Whether this filter is currently ON.
*/
private ItemStack buildFilterButton(String label, boolean active) {
ItemStack item = new ItemStack(active ? Material.LIME_DYE : Material.GRAY_DYE);
ItemMeta meta = item.getItemMeta();
if (meta == null) return item;
String prefix = active ? "&a✔ " : "&7✘ ";
meta.setDisplayName(ColorTranslator.translateColorCodes(prefix + label));
meta.setLore(List.of(ColorTranslator.translateColorCodes("&7Click to toggle")));
item.setItemMeta(meta);
return item;
}

/**
* Creates an ItemStack representing a shop
*
*
* @param foundShop The shop to create an item for
* @return An ItemStack representing the shop
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.myzticbean.finditemaddon.models;

import io.myzticbean.finditemaddon.models.enums.SortMode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

/**
* Holds a player's persistent sort and filter preferences for the shop search GUI.
* Serialized to player_prefs.json via Gson. All fields must have defaults so that
* a fresh instance (for first-time players) behaves identically to the old plugin behaviour.
*/
@Getter
@Setter
@NoArgsConstructor
public class PlayerSortFilterPrefs {
private SortMode sortMode = SortMode.DEFAULT;
private boolean filterHasStock = false;
private boolean filterSameWorld = false;
private boolean filterExcludeOwnShops = false;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.myzticbean.finditemaddon.models.enums;

/**
* Sort modes available in the in-GUI sort control.
* Ordered for intuitive cycling: DEFAULT → cheapest → most expensive → nearest.
*/
public enum SortMode {
DEFAULT,
PRICE_ASC,
PRICE_DESC,
DISTANCE_ASC;

/** Returns the next mode in the cycle, wrapping around. */
public SortMode next() {
SortMode[] values = values();
return values[(ordinal() + 1) % values.length];
}
}
Loading