diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java
index 7f897ca1..c7342143 100644
--- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java
+++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java
@@ -161,7 +161,8 @@ public void onEnable() {
itemStorageManager,
parcelDispatchService,
parcelContentManager,
- deliveryManager
+ deliveryManager,
+ config.settings.allowCollectingFromAnyLocker
);
MainGui mainGUI = new MainGui(
diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java
index 476fc339..1ae9d486 100644
--- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java
+++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java
@@ -78,6 +78,14 @@ public static class Settings extends OkaeriConfig {
@Comment({"", "# Maximum number of parcels that can be stored in a single locker"})
public int maxParcelsPerLocker = 30;
+ @Comment({
+ "",
+ "# Whether a parcel can be collected from any locker instead of only its destination locker.",
+ "# false (default): parcels can only be collected from the locker they were sent to.",
+ "# true: keeps the legacy behavior where every parcel can be collected from every locker."
+ })
+ public boolean allowCollectingFromAnyLocker = false;
+
@Comment({"", "# Small parcel fee in in-game currency"})
public double smallParcelFee = 10.0;
diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java
index f6852e7a..5ed683ef 100644
--- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java
+++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java
@@ -17,6 +17,7 @@
import com.eternalcode.parcellockers.user.User;
import com.eternalcode.parcellockers.user.UserManager;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -33,6 +34,7 @@ public class GuiManager {
private final ParcelDispatchService parcelDispatchService;
private final ParcelContentManager parcelContentManager;
private final DeliveryManager deliveryManager;
+ private final boolean allowCollectingFromAnyLocker;
public GuiManager(
ParcelService parcelService,
@@ -41,7 +43,8 @@ public GuiManager(
ItemStorageManager itemStorageManager,
ParcelDispatchService parcelDispatchService,
ParcelContentManager parcelContentManager,
- DeliveryManager deliveryManager
+ DeliveryManager deliveryManager,
+ boolean allowCollectingFromAnyLocker
) {
this.parcelService = parcelService;
this.lockerManager = lockerManager;
@@ -50,6 +53,24 @@ public GuiManager(
this.parcelDispatchService = parcelDispatchService;
this.parcelContentManager = parcelContentManager;
this.deliveryManager = deliveryManager;
+ this.allowCollectingFromAnyLocker = allowCollectingFromAnyLocker;
+ }
+
+ /**
+ * Returns the delivered parcels the receiver may collect at the locker they are interacting with.
+ * When {@code allowCollectingFromAnyLocker} is enabled the locker restriction is dropped.
+ *
+ *
{@code getCollectible} treats a {@code null} destination locker as "collect from any locker",
+ * so when the restriction is active a concrete {@code currentLocker} is required — passing
+ * {@code null} here would silently drop the restriction and let the receiver collect from any locker.
+ */
+ public CompletableFuture> getCollectibleParcels(UUID receiver, UUID currentLocker, Page page) {
+ if (this.allowCollectingFromAnyLocker) {
+ return this.parcelService.getCollectible(receiver, null, page);
+ }
+ Objects.requireNonNull(currentLocker,
+ "currentLocker must not be null when collection is restricted to the destination locker");
+ return this.parcelService.getCollectible(receiver, currentLocker, page);
}
public void sendParcel(Player sender, Parcel parcel, List items) {
diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java
index cd101250..b1e10517 100644
--- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java
+++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java
@@ -8,7 +8,6 @@
import com.eternalcode.parcellockers.gui.GuiView;
import com.eternalcode.parcellockers.gui.PaginatedGuiRefresher;
import com.eternalcode.parcellockers.parcel.Parcel;
-import com.eternalcode.parcellockers.parcel.ParcelStatus;
import com.eternalcode.parcellockers.parcel.util.PlaceholderUtil;
import com.eternalcode.parcellockers.shared.Page;
import com.eternalcode.parcellockers.util.MaterialUtil;
@@ -18,6 +17,7 @@
import dev.triumphteam.gui.guis.PaginatedGui;
import java.util.ArrayList;
import java.util.List;
+import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import net.kyori.adventure.text.Component;
@@ -35,17 +35,20 @@ public class CollectionGui implements GuiView {
private final Scheduler scheduler;
private final GuiManager guiManager;
private final MiniMessage miniMessage;
+ private final UUID currentLocker;
public CollectionGui(
GuiSettings guiSettings,
Scheduler scheduler,
GuiManager guiManager,
- MiniMessage miniMessage
+ MiniMessage miniMessage,
+ UUID currentLocker
) {
this.guiSettings = guiSettings;
this.scheduler = scheduler;
this.guiManager = guiManager;
this.miniMessage = miniMessage;
+ this.currentLocker = currentLocker;
}
@Override
@@ -66,7 +69,7 @@ public void show(Player player, Page page) {
this.setupStaticItems(player, gui);
- this.guiManager.getParcelsByReceiver(player.getUniqueId(), page).thenAccept(result -> {
+ this.guiManager.getCollectibleParcels(player.getUniqueId(), this.currentLocker, page).thenAccept(result -> {
if (result == null || result.items().isEmpty()) {
gui.setItem(22, this.guiSettings.noParcelsItem.toGuiItem());
this.scheduler.run(() -> gui.open(player));
@@ -78,7 +81,6 @@ public void show(Player player, Page page) {
this.setupNavigation(gui, page, result, player, this.guiSettings);
result.items().stream()
- .filter(parcel -> parcel.status() == ParcelStatus.DELIVERED)
.map(parcel -> this.createParcelItemAsync(parcel, parcelItem, player, refresher))
.collect(CompletableFutures.joinList())
.thenAccept(suppliers -> {
diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java
index 16dae36e..3ee62762 100644
--- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java
+++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java
@@ -49,7 +49,8 @@ public void show(Player player, UUID entryLocker) {
this.guiSettings,
this.scheduler,
this.guiManager,
- this.miniMessage
+ this.miniMessage,
+ entryLocker
);
gui.setItem(21, this.guiSettings.parcelLockerCollectItem.toGuiItem(event -> collectionGui.show(player)));
diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java
index 7f62feca..43f5b49f 100644
--- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java
+++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java
@@ -31,6 +31,18 @@ public interface ParcelRepository {
CompletableFuture> findByReceiver(UUID receiver, Page page);
+ /**
+ * Finds the parcels a receiver is allowed to collect: those addressed to them whose status is
+ * {@link com.eternalcode.parcellockers.parcel.ParcelStatus#DELIVERED}. Filtering happens in the
+ * query so pagination operates on the eligible set rather than on a raw receiver page.
+ *
+ * @param receiver the receiver whose parcels are collected
+ * @param destinationLocker when non-null, only parcels addressed to this locker are returned;
+ * when null, delivered parcels from any locker are returned
+ * @param page the requested page
+ */
+ CompletableFuture> findCollectible(UUID receiver, UUID destinationLocker, Page page);
+
/**
* Counts the parcels currently occupying a destination locker. Collected parcels are removed
* from storage, so every parcel addressed to the locker (in-transit or delivered) occupies a
diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java
index 5bdd549f..43c56008 100644
--- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java
+++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java
@@ -4,6 +4,7 @@
import com.eternalcode.parcellockers.database.DatabaseManager;
import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite;
import com.eternalcode.parcellockers.parcel.Parcel;
+import com.eternalcode.parcellockers.parcel.ParcelStatus;
import com.eternalcode.parcellockers.shared.Page;
import com.eternalcode.parcellockers.shared.PageResult;
import java.util.List;
@@ -17,6 +18,7 @@ public class ParcelRepositoryOrmLite extends AbstractRepositoryOrmLite implement
private static final String RECEIVER_COLUMN = "receiver";
private static final String SENDER_COLUMN = "sender";
private static final String DESTINATION_LOCKER_COLUMN = "destination_locker";
+ private static final String STATUS_COLUMN = "status";
public ParcelRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) {
super(databaseManager, scheduler);
@@ -78,6 +80,22 @@ public CompletableFuture> findByReceiver(UUID receiver, Page
return this.findByPaged(receiver, page, RECEIVER_COLUMN);
}
+ @Override
+ public CompletableFuture> findCollectible(UUID receiver, UUID destinationLocker, Page page) {
+ Objects.requireNonNull(receiver, "Receiver UUID cannot be null");
+ Objects.requireNonNull(page, "Page cannot be null");
+ return this.queryPage(ParcelTable.class, page, builder -> {
+ var where = builder.where()
+ .eq(RECEIVER_COLUMN, receiver)
+ .and()
+ .eq(STATUS_COLUMN, ParcelStatus.DELIVERED);
+ if (destinationLocker != null) {
+ where.and().eq(DESTINATION_LOCKER_COLUMN, destinationLocker);
+ }
+ return builder;
+ }, ParcelTable::toParcel);
+ }
+
@Override
public CompletableFuture countParcelsByDestinationLocker(UUID destinationLocker) {
Objects.requireNonNull(destinationLocker, "Destination locker UUID cannot be null");
diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java
index e733114a..abaa9cb5 100644
--- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java
+++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java
@@ -36,6 +36,14 @@ public interface ParcelService {
CompletableFuture> getByReceiver(UUID receiver, Page page);
+ /**
+ * Returns the delivered parcels a receiver may collect, optionally restricted to a single
+ * destination locker. Filtering is applied in the query so pagination stays consistent.
+ *
+ * @param destinationLocker the locker to collect from, or null to allow any locker
+ */
+ CompletableFuture> getCollectible(UUID receiver, UUID destinationLocker, Page page);
+
CompletableFuture> getAll(Page page);
CompletableFuture delete(UUID uuid);
diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java
index 0ecde6da..27a687f8 100644
--- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java
+++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java
@@ -309,6 +309,18 @@ public CompletableFuture> getByReceiver(UUID receiver, Page p
});
}
+ @Override
+ public CompletableFuture> getCollectible(UUID receiver, UUID destinationLocker, Page page) {
+ Objects.requireNonNull(receiver, "Receiver UUID cannot be null");
+ Objects.requireNonNull(page, "Page cannot be null");
+
+ return this.parcelRepository.findCollectible(receiver, destinationLocker, page)
+ .thenApply(result -> {
+ result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel));
+ return result;
+ });
+ }
+
@Override
public CompletableFuture> getAll(Page page) {
Objects.requireNonNull(page, "Page cannot be null");
diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java
new file mode 100644
index 00000000..87a2da64
--- /dev/null
+++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java
@@ -0,0 +1,110 @@
+package com.eternalcode.parcellockers.database;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.eternalcode.parcellockers.TestScheduler;
+import com.eternalcode.parcellockers.configuration.implementation.PluginConfig;
+import com.eternalcode.parcellockers.parcel.Parcel;
+import com.eternalcode.parcellockers.parcel.ParcelSize;
+import com.eternalcode.parcellockers.parcel.ParcelStatus;
+import com.eternalcode.parcellockers.parcel.repository.ParcelRepository;
+import com.eternalcode.parcellockers.parcel.repository.ParcelRepositoryOrmLite;
+import com.eternalcode.parcellockers.shared.Page;
+import com.eternalcode.parcellockers.shared.PageResult;
+import java.nio.file.Path;
+import java.sql.SQLException;
+import java.util.UUID;
+import java.util.logging.Logger;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.testcontainers.containers.MySQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+@Testcontainers(disabledWithoutDocker = true)
+class ParcelFindCollectibleIntegrationTest extends IntegrationTestSpec {
+
+ @Container
+ private static final MySQLContainer> mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest"));
+
+ @TempDir
+ private Path tempDir;
+
+ private DatabaseManager databaseManager;
+
+ private ParcelRepository repository() throws SQLException {
+ PluginConfig config = new PluginConfig();
+ config.settings.databaseType = DatabaseType.MYSQL;
+ config.settings.host = mySQLContainer.getHost();
+ config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort());
+ config.settings.databaseName = mySQLContainer.getDatabaseName();
+ config.settings.user = mySQLContainer.getUsername();
+ config.settings.password = mySQLContainer.getPassword();
+
+ DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile());
+ databaseManager.connect();
+ this.databaseManager = databaseManager;
+
+ return new ParcelRepositoryOrmLite(databaseManager, new TestScheduler());
+ }
+
+ private void save(ParcelRepository repository, UUID receiver, UUID destinationLocker, ParcelStatus status) {
+ this.await(repository.save(new Parcel(
+ UUID.randomUUID(), UUID.randomUUID(), "p", "d", false,
+ receiver, ParcelSize.SMALL, UUID.randomUUID(), destinationLocker, status)));
+ }
+
+ @Test
+ void findCollectibleReturnsOnlyDeliveredParcelsForTheGivenLockerAndReceiver() throws SQLException {
+ ParcelRepository repository = this.repository();
+
+ UUID receiver = UUID.randomUUID();
+ UUID locker = UUID.randomUUID();
+ UUID otherLocker = UUID.randomUUID();
+
+ for (int i = 0; i < 3; i++) {
+ this.save(repository, receiver, locker, ParcelStatus.DELIVERED);
+ }
+ this.save(repository, receiver, locker, ParcelStatus.SENT); // not yet delivered
+ this.save(repository, receiver, otherLocker, ParcelStatus.DELIVERED); // different locker
+ this.save(repository, UUID.randomUUID(), locker, ParcelStatus.DELIVERED); // different receiver
+
+ // Pagination must count only the 3 eligible parcels, not the raw receiver page.
+ PageResult firstPage = this.await(repository.findCollectible(receiver, locker, new Page(0, 2)));
+ assertEquals(2, firstPage.items().size());
+ assertTrue(firstPage.hasNextPage());
+
+ PageResult secondPage = this.await(repository.findCollectible(receiver, locker, new Page(1, 2)));
+ assertEquals(1, secondPage.items().size());
+ assertFalse(secondPage.hasNextPage());
+ }
+
+ @Test
+ void findCollectibleWithNullLockerReturnsDeliveredParcelsFromAnyLocker() throws SQLException {
+ ParcelRepository repository = this.repository();
+
+ UUID receiver = UUID.randomUUID();
+
+ this.save(repository, receiver, UUID.randomUUID(), ParcelStatus.DELIVERED);
+ this.save(repository, receiver, UUID.randomUUID(), ParcelStatus.DELIVERED);
+ this.save(repository, receiver, UUID.randomUUID(), ParcelStatus.SENT); // excluded by status
+ this.save(repository, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.DELIVERED); // other receiver
+
+ PageResult page = this.await(repository.findCollectible(receiver, null, new Page(0, 10)));
+ assertEquals(2, page.items().size());
+ assertFalse(page.hasNextPage());
+ assertTrue(page.items().stream().allMatch(parcel -> parcel.receiver().equals(receiver)));
+ assertTrue(page.items().stream().allMatch(parcel -> parcel.status() == ParcelStatus.DELIVERED));
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (this.databaseManager != null) {
+ this.databaseManager.disconnect();
+ }
+ }
+}