diff --git a/build.gradle.kts b/build.gradle.kts index 0098058e9..02e51118e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -133,7 +133,7 @@ tasks.withType { tasks.withType { javaLauncher = javaToolchains.launcherFor { vendor = JvmVendorSpec.JETBRAINS - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } } @@ -150,11 +150,11 @@ modrinth { tasks { runServer { - minecraftVersion("1.21.11") + minecraftVersion("26.1.2") downloadPlugins { modrinth("luckperms", "v5.5.17-bukkit") modrinth("vaultunlocked", "2.17.0") - modrinth("essentialsx", "2.21.2") + modrinth("essentialsx", "2.22.0") // modrinth("discordsrv", "1.30.4") // uncomment to test with DiscordSRV integration } jvmArgs("-Dcom.mojang.eula.agree=true", "-XX:+AllowEnhancedClassRedefinition") diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 03c31723e..13624ed76 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -71,6 +71,7 @@ import org.bstats.bukkit.Metrics; import org.bukkit.Server; import org.bukkit.command.CommandSender; +import org.bukkit.event.HandlerList; import org.bukkit.plugin.RegisteredServiceProvider; import org.bukkit.plugin.java.JavaPlugin; @@ -134,7 +135,7 @@ public void onEnable() { UserValidationService userValidationService = new UserValidator(); UserManager userManager = new UserManagerImpl(userRepository, userValidationService, server); LockerValidationService lockerValidationService = new LockerValidator(); - LockerManager lockerManager = new LockerManager(config, lockerRepository, lockerValidationService, parcelRepository, server); + LockerManager lockerManager = new LockerManager(config, lockerRepository, lockerValidationService, parcelRepository, server, scheduler); ParcelContentManager parcelContentManager = new ParcelContentManager(parcelContentRepository); ItemStorageManager itemStorageManager = new ItemStorageManager(itemStorageRepository, server); DeliveryManager deliveryManager = new DeliveryManager(deliveryRepository); @@ -230,9 +231,10 @@ public void onEnable() { @Override public void onDisable() { - if (this.databaseManager != null) { - this.databaseManager.disconnect(); - } + // Stop accepting new work before closing the datasource, so fewer in-flight async DB tasks + // run into an already-closed connection pool. + HandlerList.unregisterAll(this); + this.getServer().getScheduler().cancelTasks(this); if (this.liteCommands != null) { this.liteCommands.unregister(); @@ -241,6 +243,10 @@ public void onDisable() { if (this.discordClientManager != null) { this.discordClientManager.shutdown(); } + + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } } private boolean setupEconomy() { 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 1b893aead..8d642a50c 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java @@ -50,6 +50,19 @@ public static class Settings extends OkaeriConfig { @Comment({ "", "# The database password." }) public String password = ""; + @Comment({ "", "# Maximum number of connections held in the database pool." }) + public int connectionPoolSize = 10; + + @Comment({ "", "# How long (in milliseconds) to wait for a free connection before failing." }) + public long connectionTimeoutMillis = 5000; + + @Comment({ + "", + "# Connection leak detection threshold in milliseconds (0 disables it).", + "# Set this comfortably above your slowest expected query to avoid false warnings." + }) + public long leakDetectionThresholdMillis = 30000; + @Comment({ "", "# The parcel locker item." }) public ConfigItem parcelLockerItem = new ConfigItem() .name("&3Parcel locker") diff --git a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java index 2ffb57498..b1fd9df35 100644 --- a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java @@ -4,9 +4,6 @@ import com.eternalcode.parcellockers.content.ParcelContent; import com.eternalcode.parcellockers.database.DatabaseManager; import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; -import com.eternalcode.parcellockers.shared.exception.DatabaseException; -import com.j256.ormlite.table.TableUtils; -import java.sql.SQLException; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -15,17 +12,12 @@ public class ParcelContentRepositoryOrmLite extends AbstractRepositoryOrmLite im public ParcelContentRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { super(databaseManager, scheduler); - - try { - TableUtils.createTableIfNotExists(databaseManager.connectionSource(), ParcelContentTable.class); - } catch (SQLException exception) { - throw new DatabaseException("Failed to create ParcelContent table", exception); - } + this.createTable(ParcelContentTable.class); } @Override public CompletableFuture save(ParcelContent parcelContent) { - return this.saveIfNotExist(ParcelContentTable.class, ParcelContentTable.from(parcelContent)).thenApply(dao -> null); + return this.insertIfAbsent(ParcelContentTable.class, ParcelContentTable.from(parcelContent)).thenApply(dao -> null); } @Override diff --git a/src/main/java/com/eternalcode/parcellockers/database/DatabaseManager.java b/src/main/java/com/eternalcode/parcellockers/database/DatabaseManager.java index 52a4b52d9..7e23c45c8 100644 --- a/src/main/java/com/eternalcode/parcellockers/database/DatabaseManager.java +++ b/src/main/java/com/eternalcode/parcellockers/database/DatabaseManager.java @@ -44,13 +44,13 @@ public void connect() throws SQLException { this.dataSource.addDataSourceProperty("prepStmtCacheSqlLimit", 2048); this.dataSource.addDataSourceProperty("useServerPrepStmts", true); - this.dataSource.setMaximumPoolSize(5); - this.dataSource.setConnectionTimeout(5000); - this.dataSource.setLeakDetectionThreshold(5000); + this.dataSource.setMaximumPoolSize(this.config.settings.connectionPoolSize); + this.dataSource.setConnectionTimeout(this.config.settings.connectionTimeoutMillis); + this.dataSource.setLeakDetectionThreshold(this.config.settings.leakDetectionThresholdMillis); this.dataSource.setUsername(this.config.settings.user); this.dataSource.setPassword(this.config.settings.password); - switch (DatabaseType.valueOf(databaseType.toString().toUpperCase())) { + switch (databaseType) { case MYSQL -> { this.dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); @@ -97,18 +97,15 @@ public void disconnect() { @SuppressWarnings("unchecked") public Dao getDao(Class type) { - try { - Dao dao = this.cachedDao.get(type); - - if (dao == null) { - dao = DaoManager.createDao(this.connectionSource, type); - this.cachedDao.put(type, dao); + Dao dao = this.cachedDao.computeIfAbsent(type, key -> { + try { + return DaoManager.createDao(this.connectionSource, key); + } catch (SQLException exception) { + throw new DatabaseException("Failed to get DAO for type: " + key.getSimpleName(), exception); } + }); - return (Dao) dao; - } catch (SQLException exception) { - throw new DatabaseException("Failed to get DAO for type: " + type.getSimpleName(), exception); - } + return (Dao) dao; } public ConnectionSource connectionSource() { diff --git a/src/main/java/com/eternalcode/parcellockers/database/wrapper/AbstractRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/database/wrapper/AbstractRepositoryOrmLite.java index eb6db3875..9b736770c 100644 --- a/src/main/java/com/eternalcode/parcellockers/database/wrapper/AbstractRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/database/wrapper/AbstractRepositoryOrmLite.java @@ -3,14 +3,22 @@ import com.eternalcode.commons.ThrowingFunction; import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.parcellockers.database.DatabaseManager; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.shared.exception.DatabaseException; import com.j256.ormlite.dao.Dao; +import com.j256.ormlite.stmt.QueryBuilder; +import com.j256.ormlite.table.TableUtils; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; public abstract class AbstractRepositoryOrmLite { @@ -24,11 +32,22 @@ protected AbstractRepositoryOrmLite(DatabaseManager databaseManager, Scheduler s this.scheduler = scheduler; } - protected CompletableFuture save(Class type, T entity) { + /** Creates the backing table if it does not exist, failing fast with a {@link DatabaseException}. */ + protected void createTable(Class tableType) { + try { + TableUtils.createTableIfNotExists(this.databaseManager.connectionSource(), tableType); + } catch (SQLException exception) { + throw new DatabaseException("Failed to initialize table " + tableType.getSimpleName(), exception); + } + } + + /** Inserts the entity, or updates it if a row with the same id already exists. */ + protected CompletableFuture upsert(Class type, T entity) { return this.action(type, dao -> dao.createOrUpdate(entity)); } - protected CompletableFuture saveIfNotExist(Class type, T entity) { + /** Inserts the entity only if no row with the same id exists; an existing row is left untouched. */ + protected CompletableFuture insertIfAbsent(Class type, T entity) { return this.action(type, dao -> dao.createIfNotExists(entity)); } @@ -56,6 +75,36 @@ protected CompletableFuture> selectAll(Class type) { return this.action(type, Dao::queryForAll); } + /** + * Runs a paginated query. The {@code configure} callback may apply filters (e.g. a WHERE clause) + * to the query builder; one extra row is fetched to determine whether a following page exists. + */ + protected CompletableFuture> queryPage( + Class type, + Page page, + ThrowingFunction, QueryBuilder, SQLException> configure, + Function mapper + ) { + return this.>action(type, dao -> { + QueryBuilder builder = configure.apply(dao.queryBuilder()); + + List items = builder + .limit((long) page.getLimit() + 1) + .offset((long) page.getOffset()) + .query() + .stream() + .map(mapper) + .collect(Collectors.toCollection(ArrayList::new)); + + boolean hasNext = items.size() > page.getLimit(); + if (hasNext) { + items.removeLast(); + } + + return new PageResult<>(Collections.unmodifiableList(items), hasNext); + }); + } + protected CompletableFuture action(Class type, ThrowingFunction, R, SQLException> action) { CompletableFuture completableFuture = new CompletableFuture<>(); diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java index 6f7207f64..6a6f6c5aa 100644 --- a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java @@ -4,8 +4,6 @@ import com.eternalcode.parcellockers.database.DatabaseManager; import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; import com.eternalcode.parcellockers.delivery.Delivery; -import com.j256.ormlite.table.TableUtils; -import java.sql.SQLException; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -15,17 +13,12 @@ public class DeliveryRepositoryOrmLite extends AbstractRepositoryOrmLite impleme public DeliveryRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { super(databaseManager, scheduler); - - try { - TableUtils.createTableIfNotExists(databaseManager.connectionSource(), DeliveryTable.class); - } catch (SQLException exception) { - exception.printStackTrace(); - } + this.createTable(DeliveryTable.class); } @Override public CompletableFuture save(Delivery delivery) { - return this.saveIfNotExist(DeliveryTable.class, DeliveryTable.from(delivery)).thenApply(dao -> null); + return this.insertIfAbsent(DeliveryTable.class, DeliveryTable.from(delivery)).thenApply(dao -> null); } @Override diff --git a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java index b7835fa43..d37e04a15 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java @@ -4,10 +4,7 @@ import com.eternalcode.parcellockers.database.DatabaseManager; import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; import com.eternalcode.parcellockers.discord.DiscordLink; -import com.eternalcode.parcellockers.shared.exception.DatabaseException; import com.j256.ormlite.stmt.DeleteBuilder; -import com.j256.ormlite.table.TableUtils; -import java.sql.SQLException; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -16,17 +13,12 @@ public class DiscordLinkRepositoryOrmLite extends AbstractRepositoryOrmLite impl public DiscordLinkRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { super(databaseManager, scheduler); - - try { - TableUtils.createTableIfNotExists(databaseManager.connectionSource(), DiscordLinkEntity.class); - } catch (SQLException ex) { - throw new DatabaseException("Failed to initialize DiscordLink table", ex); - } + this.createTable(DiscordLinkEntity.class); } @Override public CompletableFuture save(DiscordLink link) { - return this.save(DiscordLinkEntity.class, DiscordLinkEntity.fromDomain(link)) + return this.upsert(DiscordLinkEntity.class, DiscordLinkEntity.fromDomain(link)) .thenApply(status -> status.isCreated() || status.isUpdated()); } diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index f2c2faded..10415a37c 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -87,8 +87,8 @@ public CompletableFuture getItemStorage(UUID owner) { .thenApply(optional -> optional.orElse(new ItemStorage(owner, List.of()))); } - public void saveItemStorage(UUID player, List items) { - this.itemStorageManager.create(player, items); + public CompletableFuture saveItemStorage(UUID player, List items) { + return this.itemStorageManager.create(player, items); } public CompletableFuture deleteItemStorage(UUID owner) { diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java index 4d963b111..0dc1d6eab 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java @@ -104,17 +104,20 @@ void show(Player player, ParcelSize size) { } this.guiManager.deleteItemStorage(player.getUniqueId()) - .thenAccept(action -> { - this.guiManager.saveItemStorage(player.getUniqueId(), items); - new SendingGui( - this.scheduler, - this.guiSettings, - this.miniMessage, - this.noticeService, - this.guiManager, - this.state - ).show(player); - }).exceptionally(FutureHandler::handleException); + .thenCompose(action -> this.guiManager.saveItemStorage(player.getUniqueId(), items)) + .thenAccept(saved -> new SendingGui( + this.scheduler, + this.guiSettings, + this.miniMessage, + this.noticeService, + this.guiManager, + this.state + ).show(player)) + .exceptionally(throwable -> { + // Persisting the staged items failed; hand them back so they are not lost. + this.scheduler.run(() -> items.forEach(item -> ItemUtil.giveItem(player, item))); + return FutureHandler.handleException(throwable); + }); }); this.guiManager.getItemStorage(player.getUniqueId()).thenAccept(itemStorage -> { diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java index 1341c0ef7..5019fe679 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java @@ -417,13 +417,7 @@ public void updateStorageItem(Player player) { } private ItemStack createActiveItem(ConfigItem item, String appendLore) { - List itemLore = new ArrayList<>(item.lore()); - itemLore.add(appendLore); - - return item.toBuilder() - .lore(itemLore.stream().map(element -> resetItalic(this.miniMessage.deserialize(element))).toList()) - .glow(true) - .build(); + return this.createActiveItem(item, List.of(appendLore)); } private ItemStack createActiveItem(ConfigItem item, List appendLore) { diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java b/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java index 9eb5860ea..358d51a26 100644 --- a/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java +++ b/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java @@ -44,11 +44,17 @@ public CompletableFuture> get(UUID parcelId) { }); } - public ItemStorage getOrCreate(UUID owner, List items) { - return this.cache.get(owner, key -> this.create(key, items)); + public CompletableFuture getOrCreate(UUID owner, List items) { + ItemStorage existing = this.cache.getIfPresent(owner); + if (existing != null) { + return CompletableFuture.completedFuture(existing); + } + // Do not call create() from inside cache.get(owner, loader): create() writes the same key + // back into the cache, and Caffeine forbids mutating the key being computed. + return this.create(owner, items); } - public ItemStorage create(UUID owner, List items) { + public CompletableFuture create(UUID owner, List items) { ItemStorage oldItemStorage = this.cache.getIfPresent(owner); ItemStorage newItemStorage = new ItemStorage(owner, items); @@ -57,12 +63,24 @@ public ItemStorage create(UUID owner, List items) { this.server.getPluginManager().callEvent(event); if (event.isCancelled()) { - throw new IllegalStateException("ItemStorage update was cancelled by event"); + return CompletableFuture.failedFuture(new IllegalStateException("ItemStorage update was cancelled by event")); } this.cache.put(owner, newItemStorage); - this.itemStorageRepository.save(newItemStorage); - return newItemStorage; + // Return the save future so callers can react to a persistence failure instead of losing items. + // If the save fails, undo the optimistic cache update so the cache never holds an unpersisted + // storage (which, combined with re-giving the items to the player, would duplicate them). + return this.itemStorageRepository.save(newItemStorage) + .whenComplete((ignored, throwable) -> { + if (throwable != null) { + if (oldItemStorage != null) { + this.cache.put(owner, oldItemStorage); + } else { + this.cache.invalidate(owner); + } + } + }) + .thenApply(ignored -> newItemStorage); } private void cacheAll() { diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/event/ItemStorageUpdateEvent.java b/src/main/java/com/eternalcode/parcellockers/itemstorage/event/ItemStorageUpdateEvent.java index bebb56fcb..57bdf4197 100644 --- a/src/main/java/com/eternalcode/parcellockers/itemstorage/event/ItemStorageUpdateEvent.java +++ b/src/main/java/com/eternalcode/parcellockers/itemstorage/event/ItemStorageUpdateEvent.java @@ -1,20 +1,17 @@ package com.eternalcode.parcellockers.itemstorage.event; import com.eternalcode.parcellockers.itemstorage.ItemStorage; -import org.bukkit.event.Cancellable; -import org.bukkit.event.Event; +import com.eternalcode.parcellockers.shared.event.CancellableEvent; import org.bukkit.event.HandlerList; import org.jetbrains.annotations.NotNull; -public class ItemStorageUpdateEvent extends Event implements Cancellable { +public class ItemStorageUpdateEvent extends CancellableEvent { private static final HandlerList HANDLER_LIST = new HandlerList(); private final ItemStorage oldItemStorage; private final ItemStorage updatedItemStorage; - private boolean cancelled; - public ItemStorageUpdateEvent(ItemStorage oldItemStorage, ItemStorage updatedItemStorage) { super(true); this.oldItemStorage = oldItemStorage; @@ -33,16 +30,6 @@ public ItemStorage getUpdatedItemStorage() { return this.updatedItemStorage; } - @Override - public boolean isCancelled() { - return this.cancelled; - } - - @Override - public void setCancelled(boolean cancel) { - this.cancelled = cancel; - } - @Override public @NotNull HandlerList getHandlers() { return HANDLER_LIST; diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepositoryOrmLite.java index 65b3e9c7b..9c7416854 100644 --- a/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepositoryOrmLite.java @@ -4,8 +4,6 @@ import com.eternalcode.parcellockers.database.DatabaseManager; import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; import com.eternalcode.parcellockers.itemstorage.ItemStorage; -import com.j256.ormlite.table.TableUtils; -import java.sql.SQLException; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -15,17 +13,12 @@ public class ItemStorageRepositoryOrmLite extends AbstractRepositoryOrmLite impl public ItemStorageRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { super(databaseManager, scheduler); - - try { - TableUtils.createTableIfNotExists(databaseManager.connectionSource(), ItemStorageTable.class); - } catch (SQLException ex) { - ex.printStackTrace(); - } + this.createTable(ItemStorageTable.class); } @Override public CompletableFuture save(ItemStorage itemStorage) { - return this.saveIfNotExist(ItemStorageTable.class, ItemStorageTable.from(itemStorage.owner(), itemStorage.items())).thenApply(dao -> null); + return this.insertIfAbsent(ItemStorageTable.class, ItemStorageTable.from(itemStorage.owner(), itemStorage.items())).thenApply(dao -> null); } @Override diff --git a/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java b/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java index b3fe08032..c04e12449 100644 --- a/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java +++ b/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java @@ -1,5 +1,6 @@ package com.eternalcode.parcellockers.locker; +import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; import com.eternalcode.parcellockers.locker.event.LockerCreateEvent; import com.eternalcode.parcellockers.locker.event.LockerDeleteEvent; @@ -15,7 +16,6 @@ import com.github.benmanes.caffeine.cache.Caffeine; import eu.okaeri.configs.exception.ValidationException; import java.time.Duration; -import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -30,6 +30,7 @@ public class LockerManager { private final LockerValidationService validationService; private final ParcelRepository parcelRepository; private final Server server; + private final Scheduler scheduler; private final Cache lockersByUUID; private final Cache lockersByPosition; @@ -39,13 +40,15 @@ public LockerManager( LockerRepository lockerRepository, LockerValidationService validationService, ParcelRepository parcelRepository, - Server server + Server server, + Scheduler scheduler ) { this.config = config; this.lockerRepository = lockerRepository; this.validationService = validationService; this.parcelRepository = parcelRepository; this.server = server; + this.scheduler = scheduler; this.lockersByUUID = Caffeine.newBuilder() .expireAfterAccess(Duration.ofHours(2)) @@ -77,6 +80,15 @@ public CompletableFuture> get(UUID uniqueId) { }); } + /** + * Synchronous, cache-only lookup. Lets event handlers that must run on the main thread + * (e.g. block protection) decide whether to cancel an event within the same tick instead + * of cancelling asynchronously after the event has already been processed. + */ + public Optional getCached(Position position) { + return Optional.ofNullable(this.lockersByPosition.getIfPresent(position)); + } + public CompletableFuture> get(Position position) { Locker locker = this.lockersByPosition.getIfPresent(position); @@ -94,12 +106,16 @@ public CompletableFuture> get(Position position) { } public CompletableFuture> get(Page page) { - List cached = List.copyOf(this.lockersByUUID.asMap().values()); - boolean hasNextPage = cached.size() > page.getLimit(); - if (!cached.isEmpty() && page.getOffset() == 0 && !hasNextPage) { - return CompletableFuture.completedFuture(new PageResult<>(cached, false)); - } - return this.lockerRepository.findPage(page); + // The cache holds an arbitrary, partially-evicted subset of lockers - it is not the full + // dataset, so it cannot answer pagination. Always query the repository (warming the cache + // with the results for subsequent single-locker lookups). + return this.lockerRepository.findPage(page).thenApply(result -> { + result.items().forEach(locker -> { + this.lockersByUUID.put(locker.uuid(), locker); + this.lockersByPosition.put(locker.position(), locker); + }); + return result; + }); } public CompletableFuture create(UUID uniqueId, String name, Position position, UUID playerUUID) { @@ -133,7 +149,16 @@ public CompletableFuture create(UUID uniqueId, String name, Position pos this.lockersByUUID.put(uniqueId, locker); this.lockersByPosition.put(position, locker); - return this.lockerRepository.save(locker); + // Undo the optimistic cache entries if the persist fails, so the cache never holds a + // locker that is not in the database (which would break the break/interaction fast paths + // and reject re-placement at the same position until the cache expires). + return this.lockerRepository.save(locker) + .whenComplete((saved, throwable) -> { + if (throwable != null) { + this.lockersByUUID.invalidate(uniqueId); + this.lockersByPosition.invalidate(position); + } + }); }).thenCompose(Function.identity()); } @@ -149,15 +174,25 @@ public CompletableFuture delete(UUID uniqueId, UUID playerUUID) { return CompletableFuture.completedFuture(null); } + // The locker may be resolved on either the main thread (cache hit) or an async DB thread + // (cache miss), so fire the synchronous event on the main thread deterministically. + return this.fireDeleteEvent(locker, playerUUID).thenCompose(cancelled -> { + if (cancelled) { + return CompletableFuture.completedFuture(null); + } + return this.deleteLocker(uniqueId); + }); + }); + } + + private CompletableFuture fireDeleteEvent(Locker locker, UUID playerUUID) { + CompletableFuture cancelled = new CompletableFuture<>(); + this.scheduler.run(() -> { LockerDeleteEvent event = new LockerDeleteEvent(locker, playerUUID); this.server.getPluginManager().callEvent(event); - - if (event.isCancelled()) { - return CompletableFuture.completedFuture(null); - } - - return this.deleteLocker(uniqueId); + cancelled.complete(event.isCancelled()); }); + return cancelled; } public CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService) { @@ -174,8 +209,8 @@ public CompletableFuture deleteAll(CommandSender sender, NoticeService not } public CompletableFuture isLockerFull(UUID uniqueId) { - return this.parcelRepository.countDeliveredParcelsByDestinationLocker(uniqueId) - .thenApply(count -> count > 0 && count >= this.config.settings.maxParcelsPerLocker); + return this.parcelRepository.countParcelsByDestinationLocker(uniqueId) + .thenApply(count -> count >= this.config.settings.maxParcelsPerLocker); } private CompletableFuture deleteLocker(UUID uniqueId) { diff --git a/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerBreakController.java b/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerBreakController.java index 031809011..89a944a6c 100644 --- a/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerBreakController.java +++ b/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerBreakController.java @@ -2,11 +2,14 @@ import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.multification.shared.Formatter; +import com.eternalcode.parcellockers.locker.Locker; import com.eternalcode.parcellockers.locker.LockerManager; import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.shared.Position; import com.eternalcode.parcellockers.shared.PositionAdapter; +import java.util.Optional; import org.bukkit.Location; +import org.bukkit.event.Cancellable; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; @@ -42,67 +45,92 @@ public void onBlockBreak(BlockBreakEvent event) { Position position = PositionAdapter.convert(location); Player player = event.getPlayer(); - this.lockerManager.get(position).thenAccept((locker) -> { - if (locker.isEmpty()) { - return; - } + // Fast path: the locker is cached, so we can decide synchronously and cancel the break + // before vanilla destroys the block and spawns its drops. + Optional cached = this.lockerManager.getCached(position); + if (cached.isPresent()) { + event.setCancelled(true); + this.applyBreakRules(player, position, cached.get(), block); + return; + } + // Slow path: the locker is not cached, so the break has already been processed by the + // time the async lookup completes. Restore the block to avoid losing the locker. + this.lockerManager.get(position).thenAccept(locker -> locker.ifPresent(value -> this.scheduler.run(() -> { - if (!player.hasPermission("parcellockers.admin.break")) { - // We assume that the event was already processed and the block is gone, - // so we need to restore it manually - location.getBlock().setType(block.getType()); - location.getBlock().setBlockData(blockData); - this.noticeService.player(player.getUniqueId(), messages -> messages.locker.cannotBreak); - return; - } - - this.lockerManager.delete(locker.get().uuid(), player.getUniqueId()); - - this.noticeService.player(player.getUniqueId(), messages -> messages.locker.deleted); - - Formatter formatter = new Formatter() - .register("{X}", position.x()) - .register("{Y}", position.y()) - .register("{Z}", position.z()) - .register("{WORLD}", position.world()) - .register("{PLAYER}", player.getName()); - - this.noticeService.create() - .onlinePlayers() - .notice(messages -> messages.locker.broadcastRemoved) - .formatter(formatter) - .send(); - }); - }); + location.getBlock().setType(block.getType()); + location.getBlock().setBlockData(blockData); + this.applyBreakRules(player, position, value, location.getBlock()); + }))); + } + + private void applyBreakRules(Player player, Position position, Locker locker, Block block) { + if (!player.hasPermission("parcellockers.admin.break")) { + this.noticeService.player(player.getUniqueId(), messages -> messages.locker.cannotBreak); + return; + } + + // Admin removal: delete the managed locker and clear the block without dropping it. + block.setType(Material.AIR); + + this.lockerManager.delete(locker.uuid(), player.getUniqueId()); + + this.noticeService.player(player.getUniqueId(), messages -> messages.locker.deleted); + + Formatter formatter = new Formatter() + .register("{X}", position.x()) + .register("{Y}", position.y()) + .register("{Z}", position.z()) + .register("{WORLD}", position.world()) + .register("{PLAYER}", player.getName()); + + this.noticeService.create() + .onlinePlayers() + .notice(messages -> messages.locker.broadcastRemoved) + .formatter(formatter) + .send(); } @EventHandler public void onEntityExplode(EntityExplodeEvent event) { + // Remove cached locker blocks from the explosion synchronously so they are never destroyed + // (and never drop their vanilla chest item). + event.blockList().removeIf(block -> block.getType() == Material.CHEST + && this.lockerManager.getCached(PositionAdapter.convert(block.getLocation())).isPresent()); + + // Uncached lockers cannot be decided synchronously; restore them best-effort after the blast. for (Block block : event.blockList()) { - if (block.getType() != Material.CHEST) { - continue; + if (block.getType() == Material.CHEST) { + this.restoreIfUncachedLocker(block); } - this.handleDamagedLocker(block); } } @EventHandler public void onBlockIgnite(BlockIgniteEvent event) { - this.handleDamagedLocker(event.getBlock()); + this.protectFromDamage(event, event.getBlock()); } @EventHandler public void onBlockBurn(BlockBurnEvent event) { - this.handleDamagedLocker(event.getBlock()); + this.protectFromDamage(event, event.getBlock()); } @EventHandler public void onBlockDamage(BlockDamageEvent event) { - this.handleDamagedLocker(event.getBlock()); + this.protectFromDamage(event, event.getBlock()); + } + + private void protectFromDamage(Cancellable event, Block block) { + // Cancel synchronously when the locker is known, otherwise fall back to an async restore. + if (this.lockerManager.getCached(PositionAdapter.convert(block.getLocation())).isPresent()) { + event.setCancelled(true); + return; + } + this.restoreIfUncachedLocker(block); } - private void handleDamagedLocker(Block block) { + private void restoreIfUncachedLocker(Block block) { BlockData blockData = block.getBlockData(); Location location = block.getLocation(); Position position = PositionAdapter.convert(location); diff --git a/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerInteractionController.java b/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerInteractionController.java index 9ecfe24b3..e843febc6 100644 --- a/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerInteractionController.java +++ b/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerInteractionController.java @@ -2,8 +2,11 @@ import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.parcellockers.gui.implementation.locker.LockerGui; +import com.eternalcode.parcellockers.locker.Locker; import com.eternalcode.parcellockers.locker.LockerManager; +import com.eternalcode.parcellockers.shared.Position; import com.eternalcode.parcellockers.shared.PositionAdapter; +import java.util.Optional; import java.util.UUID; import org.bukkit.Material; import org.bukkit.block.Block; @@ -38,16 +41,23 @@ public void onInventoryOpen(PlayerInteractEvent event) { return; } - this.lockerManager.get(PositionAdapter.convert(block.getLocation())).thenAccept(optionalLocker -> { - if (optionalLocker.isEmpty()) { - return; - } - UUID uuid = optionalLocker.get().uuid(); + Position position = PositionAdapter.convert(block.getLocation()); + // Fast path: cancel the interaction synchronously so the vanilla chest never opens. + Optional cached = this.lockerManager.getCached(position); + if (cached.isPresent()) { + event.setCancelled(true); + UUID uuid = cached.get().uuid(); + this.scheduler.run(() -> this.lockerGUI.show(player, uuid)); + return; + } + + // Slow path: the locker is not cached, so the vanilla chest has already opened by the time + // the async lookup completes. Close it and open the locker GUI instead (warming the cache). + this.lockerManager.get(position).thenAccept(optionalLocker -> optionalLocker.ifPresent(locker -> this.scheduler.run(() -> { - event.setCancelled(true); - this.lockerGUI.show(player, uuid); - }); - }); + player.closeInventory(); + this.lockerGUI.show(player, locker.uuid()); + }))); } } diff --git a/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerPlaceController.java b/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerPlaceController.java index 21f1206be..b046a4251 100644 --- a/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerPlaceController.java +++ b/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerPlaceController.java @@ -1,5 +1,6 @@ package com.eternalcode.parcellockers.locker.controller; +import com.eternalcode.commons.bukkit.ItemUtil; import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; @@ -22,6 +23,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickCallback; import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; @@ -89,7 +91,7 @@ public void onBlockPlace(BlockPlaceEvent event) { this.lockerCreators.put(player.getUniqueId(), true); - Component promptMessage = this.miniMessage.deserialize(this.messages.locker.descriptionPrompt); // Replace with actual config message + Component promptMessage = this.miniMessage.deserialize(this.messages.locker.descriptionPrompt); Dialog dialog = Dialog.create(builder -> builder.empty() .base(DialogBase.builder(promptMessage) @@ -124,27 +126,47 @@ public void onBlockPlace(BlockPlaceEvent event) { } this.scheduler.run(() -> { - location.getWorld().getBlockAt(location).setType(type); - location.getWorld().getBlockAt(location).setBlockData(data); - }); - - this.lockerManager.create(UUID.randomUUID(), description, PositionAdapter.convert(location), player.getUniqueId()) - .thenAccept(locker -> { - this.noticeService.create() - .player(player.getUniqueId()) - .notice(messages -> messages.locker.created) - .send(); - - this.lockerCreators.invalidate(player.getUniqueId()); - }) - .exceptionally(ex -> { + // The original BlockPlaceEvent was cancelled, so the item was never consumed. + // Consume it up front; if the player no longer holds it, abort instead of + // creating a free locker. + if (!this.consumeLockerItem(player, parcelLockerItem)) { this.noticeService.create() .player(player.getUniqueId()) .notice(messages -> messages.locker.cannotCreate) .send(); this.lockerCreators.invalidate(player.getUniqueId()); - return null; - }); + return; + } + + location.getWorld().getBlockAt(location).setType(type); + location.getWorld().getBlockAt(location).setBlockData(data); + + this.lockerManager.create(UUID.randomUUID(), description, PositionAdapter.convert(location), player.getUniqueId()) + .thenAccept(locker -> { + this.noticeService.create() + .player(player.getUniqueId()) + .notice(messages -> messages.locker.created) + .send(); + + this.lockerCreators.invalidate(player.getUniqueId()); + }) + .exceptionally(ex -> { + // Creation failed after the item was consumed and the block placed: + // refund the item and clear the block so nothing is lost. + this.scheduler.run(() -> { + ItemStack refund = parcelLockerItem.clone(); + refund.setAmount(1); + ItemUtil.giveItem(player, refund); + location.getWorld().getBlockAt(location).setType(Material.AIR); + }); + this.noticeService.create() + .player(player.getUniqueId()) + .notice(messages -> messages.locker.cannotCreate) + .send(); + this.lockerCreators.invalidate(player.getUniqueId()); + return null; + }); + }); }); }, ClickCallback.Options.builder() .uses(1) @@ -167,4 +189,26 @@ public void onBlockPlace(BlockPlaceEvent event) { player.showDialog(dialog); } + + private boolean consumeLockerItem(Player player, ItemStack lockerItem) { + if (player.getGameMode() == GameMode.CREATIVE) { + return true; + } + + PlayerInventory inventory = player.getInventory(); + + ItemStack mainHand = inventory.getItemInMainHand(); + if (lockerItem.isSimilar(mainHand) && mainHand.getAmount() > 0) { + mainHand.setAmount(mainHand.getAmount() - 1); + return true; + } + + ItemStack offHand = inventory.getItemInOffHand(); + if (lockerItem.isSimilar(offHand) && offHand.getAmount() > 0) { + offHand.setAmount(offHand.getAmount() - 1); + return true; + } + + return false; + } } diff --git a/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java b/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java index cc5f5e825..014670d16 100644 --- a/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java +++ b/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java @@ -1,19 +1,17 @@ package com.eternalcode.parcellockers.locker.event; import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.shared.event.CancellableEvent; import java.util.UUID; -import org.bukkit.event.Cancellable; -import org.bukkit.event.Event; import org.bukkit.event.HandlerList; import org.jetbrains.annotations.NotNull; -public class LockerCreateEvent extends Event implements Cancellable { +public class LockerCreateEvent extends CancellableEvent { private static final HandlerList HANDLER_LIST = new HandlerList(); private final Locker locker; private final UUID player; - private boolean cancelled; public LockerCreateEvent(Locker locker, UUID player) { super(true); @@ -33,19 +31,8 @@ public UUID getPlayer() { return this.player; } - @Override - public boolean isCancelled() { - return this.cancelled; - } - - @Override - public void setCancelled(boolean cancel) { - this.cancelled = cancel; - } - @Override public @NotNull HandlerList getHandlers() { return HANDLER_LIST; } - } diff --git a/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java b/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java index 232dfdbd3..281dfc4cd 100644 --- a/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java +++ b/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java @@ -1,21 +1,19 @@ package com.eternalcode.parcellockers.locker.event; import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.shared.event.CancellableEvent; import java.util.UUID; -import org.bukkit.event.Cancellable; -import org.bukkit.event.Event; import org.bukkit.event.HandlerList; import org.jetbrains.annotations.NotNull; -// Called when a locker is deleted +// Called when a locker is deleted. Fired synchronously on the main thread (see LockerManager#delete). // Warning: this event is not called when all lockers are deleted through "/parcel debug delete lockers" command -public class LockerDeleteEvent extends Event implements Cancellable { +public class LockerDeleteEvent extends CancellableEvent { private static final HandlerList HANDLER_LIST = new HandlerList(); private final Locker locker; private final UUID player; - private boolean cancelled; public LockerDeleteEvent(Locker locker, UUID player) { this.locker = locker; @@ -34,19 +32,8 @@ public UUID getPlayer() { return this.player; } - @Override - public boolean isCancelled() { - return this.cancelled; - } - - @Override - public void setCancelled(boolean cancel) { - this.cancelled = cancel; - } - @Override public @NotNull HandlerList getHandlers() { return HANDLER_LIST; } - } diff --git a/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java index 6c5e69568..dae094708 100644 --- a/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java @@ -7,30 +7,21 @@ import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.shared.Position; -import com.eternalcode.parcellockers.shared.exception.DatabaseException; -import com.j256.ormlite.table.TableUtils; -import java.sql.SQLException; import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; public class LockerRepositoryOrmLite extends AbstractRepositoryOrmLite implements LockerRepository { public LockerRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { super(databaseManager, scheduler); - - try { - TableUtils.createTableIfNotExists(databaseManager.connectionSource(), LockerTable.class); - } catch (SQLException ex) { - throw new DatabaseException("Failed to initialize locker table", ex); - } + this.createTable(LockerTable.class); } @Override public CompletableFuture save(Locker locker) { - return this.saveIfNotExist(LockerTable.class, LockerTable.from(locker)).thenApply(LockerTable::toLocker); + return this.insertIfAbsent(LockerTable.class, LockerTable.from(locker)).thenApply(LockerTable::toLocker); } @Override @@ -59,22 +50,7 @@ public CompletableFuture delete(Locker locker) { @Override public CompletableFuture> findPage(Page page) { - return this.action( - LockerTable.class, dao -> { - List lockers = dao.queryBuilder() - .offset((long) page.getOffset()) - .limit((long) page.getLimit() + 1) - .query() - .stream().map(LockerTable::toLocker) - .collect(Collectors.toList()); - - boolean hasNext = lockers.size() > page.getLimit(); - if (hasNext) { - lockers.removeLast(); - } - - return new PageResult<>(lockers, hasNext); - }); + return this.queryPage(LockerTable.class, page, builder -> builder, LockerTable::toLocker); } @Override diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java b/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java index 7cd54e99e..45fbc8ff0 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java @@ -1,17 +1,15 @@ package com.eternalcode.parcellockers.parcel.event; import com.eternalcode.parcellockers.parcel.Parcel; -import org.bukkit.event.Cancellable; -import org.bukkit.event.Event; +import com.eternalcode.parcellockers.shared.event.CancellableEvent; import org.bukkit.event.HandlerList; import org.jetbrains.annotations.NotNull; -public class ParcelCollectEvent extends Event implements Cancellable { +public class ParcelCollectEvent extends CancellableEvent { private static final HandlerList HANDLER_LIST = new HandlerList(); private final Parcel parcel; - private boolean cancelled; public ParcelCollectEvent(Parcel parcel) { this.parcel = parcel; @@ -29,14 +27,4 @@ public Parcel getParcel() { public @NotNull HandlerList getHandlers() { return HANDLER_LIST; } - - @Override - public boolean isCancelled() { - return this.cancelled; - } - - @Override - public void setCancelled(boolean cancel) { - this.cancelled = cancel; - } } diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java b/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java index 47ac5347d..dd8a98556 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java @@ -1,17 +1,15 @@ package com.eternalcode.parcellockers.parcel.event; import com.eternalcode.parcellockers.parcel.Parcel; -import org.bukkit.event.Cancellable; -import org.bukkit.event.Event; +import com.eternalcode.parcellockers.shared.event.CancellableEvent; import org.bukkit.event.HandlerList; import org.jetbrains.annotations.NotNull; -public class ParcelDeliverEvent extends Event implements Cancellable { +public class ParcelDeliverEvent extends CancellableEvent { private static final HandlerList HANDLER_LIST = new HandlerList(); private final Parcel parcel; - private boolean cancelled; public ParcelDeliverEvent(Parcel parcel) { super(true); @@ -30,14 +28,4 @@ public Parcel getParcel() { public @NotNull HandlerList getHandlers() { return HANDLER_LIST; } - - @Override - public boolean isCancelled() { - return this.cancelled; - } - - @Override - public void setCancelled(boolean cancel) { - this.cancelled = cancel; - } } diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java b/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java index c1c83d695..68395516c 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java @@ -1,17 +1,15 @@ package com.eternalcode.parcellockers.parcel.event; import com.eternalcode.parcellockers.parcel.Parcel; -import org.bukkit.event.Cancellable; -import org.bukkit.event.Event; +import com.eternalcode.parcellockers.shared.event.CancellableEvent; import org.bukkit.event.HandlerList; import org.jetbrains.annotations.NotNull; -public class ParcelSendEvent extends Event implements Cancellable { +public class ParcelSendEvent extends CancellableEvent { private static final HandlerList HANDLER_LIST = new HandlerList(); private final Parcel parcel; - private boolean cancelled; public ParcelSendEvent(Parcel parcel) { super(true); @@ -30,14 +28,4 @@ public Parcel getParcel() { public @NotNull HandlerList getHandlers() { return HANDLER_LIST; } - - @Override - public boolean isCancelled() { - return this.cancelled; - } - - @Override - public void setCancelled(boolean cancel) { - this.cancelled = cancel; - } } 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 1997ace4b..b738281a1 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java @@ -29,7 +29,12 @@ public interface ParcelRepository { CompletableFuture> findByReceiver(UUID receiver, Page page); - CompletableFuture countDeliveredParcelsByDestinationLocker(UUID destinationLocker); + /** + * 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 + * slot. Counting in-transit parcels reserves a slot at send time and closes the fullness race. + */ + CompletableFuture countParcelsByDestinationLocker(UUID destinationLocker); CompletableFuture delete(Parcel parcel); 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 52d944b0a..95fda22ba 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java @@ -4,48 +4,35 @@ 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 com.eternalcode.parcellockers.shared.exception.DatabaseException; -import com.j256.ormlite.table.TableUtils; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; public class ParcelRepositoryOrmLite extends AbstractRepositoryOrmLite implements ParcelRepository { 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); - - try { - TableUtils.createTableIfNotExists(databaseManager.connectionSource(), ParcelTable.class); - } catch (SQLException ex) { - throw new DatabaseException("Failed to initialize parcel table", ex); - } + this.createTable(ParcelTable.class); } @Override public CompletableFuture save(Parcel parcel) { Objects.requireNonNull(parcel, "Parcel cannot be null"); - return this.saveIfNotExist(ParcelTable.class, ParcelTable.from(parcel)).thenApply(dao -> null); + return this.insertIfAbsent(ParcelTable.class, ParcelTable.from(parcel)).thenApply(dao -> null); } @Override public CompletableFuture update(Parcel parcel) { Objects.requireNonNull(parcel, "Parcel cannot be null"); - return this.save(ParcelTable.class, ParcelTable.from(parcel)).thenApply(dao -> null); + return this.upsert(ParcelTable.class, ParcelTable.from(parcel)).thenApply(dao -> null); } @Override @@ -92,39 +79,22 @@ public CompletableFuture> findByReceiver(UUID receiver, Page } @Override - public CompletableFuture countDeliveredParcelsByDestinationLocker(UUID destinationLocker) { + public CompletableFuture countParcelsByDestinationLocker(UUID destinationLocker) { Objects.requireNonNull(destinationLocker, "Destination locker UUID cannot be null"); return this.action(ParcelTable.class, dao -> { long count = dao.queryBuilder() .where() .eq(DESTINATION_LOCKER_COLUMN, destinationLocker) - .and() - .eq(STATUS_COLUMN, ParcelStatus.DELIVERED) .countOf(); return (int) count; }); } private CompletableFuture> findByPaged(UUID key, Page page, String column) { - return this.action( - ParcelTable.class, dao -> { - List parcels = dao.queryBuilder() - .limit((long) page.getLimit() + 1) - .offset((long) page.getOffset()) - .where() - .eq(column, key) - .query() - .stream() - .map(ParcelTable::toParcel) - .collect(Collectors.toCollection(ArrayList::new)); - - boolean hasNext = parcels.size() > page.getLimit(); - if (hasNext) { - parcels.removeLast(); - } - - return new PageResult<>(Collections.unmodifiableList(parcels), hasNext); - }); + return this.queryPage(ParcelTable.class, page, builder -> { + builder.where().eq(column, key); + return builder; + }, ParcelTable::toParcel); } @Override diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java index 2a4ab9b1a..3dd92024b 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java @@ -11,7 +11,9 @@ import java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -28,6 +30,10 @@ public class ParcelDispatchService { private final PluginConfig config; private final NoticeService noticeService; + // Serializes dispatches per destination locker so that two concurrent sends cannot both pass + // the fullness check before either parcel is persisted (a TOCTOU that could exceed the cap). + private final ConcurrentHashMap> lockerChains = new ConcurrentHashMap<>(); + public ParcelDispatchService( LockerManager lockerManager, ParcelService parcelService, @@ -47,7 +53,21 @@ public ParcelDispatchService( } public void dispatch(Player sender, Parcel parcel, List items) { - this.lockerManager.isLockerFull(parcel.destinationLocker()) + UUID lockerId = parcel.destinationLocker(); + + CompletableFuture chained = this.lockerChains.compute(lockerId, (id, previous) -> { + CompletableFuture predecessor = previous == null + ? CompletableFuture.completedFuture(null) + : previous.exceptionally(throwable -> null); + return predecessor.thenCompose(ignored -> this.dispatchInternal(sender, parcel, items)); + }); + + // Drop the chain entry once it drains so the map does not grow unbounded. + chained.whenComplete((result, throwable) -> this.lockerChains.remove(lockerId, chained)); + } + + private CompletableFuture dispatchInternal(Player sender, Parcel parcel, List items) { + return this.lockerManager.isLockerFull(parcel.destinationLocker()) .thenCompose(isFull -> { if (isFull) { this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.lockerFull); @@ -66,11 +86,16 @@ public void dispatch(Player sender, Parcel parcel, List items) { } return this.itemStorageManager.delete(sender.getUniqueId()) - .thenAccept(deleted -> { + // A failed delete must trigger the rollback, not skip straight to the outer + // exceptionally handler (which would leave the parcel sent and the fee charged). + .exceptionally(throwable -> false) + .thenCompose(deleted -> { if (!Boolean.TRUE.equals(deleted)) { - this.parcelService.delete(parcel.uuid()); + // The parcel and its content were already persisted and the fee charged, + // but the sender's staged storage could not be cleared. Fully roll back + // (parcel + content + fee) instead of leaving orphaned content behind. this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); - return; + return this.parcelService.rollbackSend(sender, parcel); } this.deliveryManager.create(parcel.uuid(), Instant.now().plus(delay)); @@ -82,6 +107,10 @@ public void dispatch(Player sender, Parcel parcel, List items) { ); this.scheduler.runLaterAsync(task, delay); + // Only confirm success here, once every step has succeeded, to avoid a + // "sent" notice immediately followed by "cannot send" on a rollback. + this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.sent); + return CompletableFuture.completedFuture(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 f17a4f25b..9d58fd50f 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java @@ -16,6 +16,12 @@ public interface ParcelService { CompletableFuture send(Player sender, Parcel parcel, List items); + /** + * Rolls back a parcel that was successfully persisted by {@link #send} but could not be + * fully dispatched. Deletes both the parcel and its content and refunds the send fee. + */ + CompletableFuture rollbackSend(Player sender, Parcel parcel); + CompletableFuture update(Parcel parcel); CompletableFuture collect(Player player, Parcel parcel); 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 8db7f3239..d89b88f6c 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -1,6 +1,6 @@ package com.eternalcode.parcellockers.parcel.service; -import static com.eternalcode.parcellockers.util.InventoryUtil.freeSlotsInInventory; +import static com.eternalcode.parcellockers.util.InventoryUtil.canHold; import com.eternalcode.commons.bukkit.ItemUtil; import com.eternalcode.commons.scheduler.Scheduler; @@ -9,6 +9,7 @@ import com.eternalcode.parcellockers.content.repository.ParcelContentRepository; import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; import com.eternalcode.parcellockers.parcel.event.ParcelCollectEvent; import com.eternalcode.parcellockers.parcel.event.ParcelSendEvent; import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; @@ -91,12 +92,9 @@ public CompletableFuture send(Player sender, Parcel parcel, List this.config.settings.smallParcelFee; - case MEDIUM -> this.config.settings.mediumParcelFee; - case LARGE -> this.config.settings.largeParcelFee; - }; + double fee = this.feeFor(parcel.size()); if (fee > 0) { boolean success = this.economy.withdrawPlayer(sender, fee).transactionSuccess(); @@ -111,6 +109,7 @@ public CompletableFuture send(Player sender, Parcel parcel, List messages.parcel.feeWithdrawn) .player(sender.getUniqueId()) @@ -119,25 +118,55 @@ public CompletableFuture send(Player sender, Parcel parcel, List this.parcelContentRepository.save(new ParcelContent(parcel.uuid(), itemsCopy)) .thenApply(contentSaved -> { this.parcelsByUuid.put(parcel.uuid(), parcel); - this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.sent); + // The "sent" notice is issued by the dispatcher once the whole send succeeds, so it + // is not shown when a later step (e.g. clearing storage) fails and rolls back. return true; }) - .exceptionallyCompose(contentError -> { - this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); - return this.parcelRepository.delete(parcel.uuid()) - .thenCompose(deleted -> CompletableFuture.failedFuture(new ParcelOperationException("Failed to save parcel content, rolled back parcel", contentError))); - }) + .exceptionallyCompose(contentError -> this.parcelRepository.delete(parcel.uuid()) + .thenCompose(deleted -> CompletableFuture.failedFuture(new ParcelOperationException("Failed to save parcel content, rolled back parcel", contentError)))) ) .exceptionally(throwable -> { + // Persistence failed after the fee was withdrawn - refund it so the player is not charged for a parcel that was never created. + this.refundFee(sender, refundableFee); this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); throw new ParcelOperationException("Failed to save parcel", throwable); }); } + @Override + public CompletableFuture rollbackSend(Player sender, Parcel parcel) { + Objects.requireNonNull(sender, "Sender cannot be null"); + Objects.requireNonNull(parcel, "Parcel cannot be null"); + + if (!sender.hasPermission(PARCEL_FEE_BYPASS_PERMISSION)) { + this.refundFee(sender, this.feeFor(parcel.size())); + } + this.parcelsByUuid.invalidate(parcel.uuid()); + + return this.parcelRepository.delete(parcel.uuid()) + .thenCompose(deleted -> this.parcelContentRepository.delete(parcel.uuid())) + .thenApply(contentDeleted -> null); + } + + private double feeFor(ParcelSize size) { + return switch (size) { + case SMALL -> this.config.settings.smallParcelFee; + case MEDIUM -> this.config.settings.mediumParcelFee; + case LARGE -> this.config.settings.largeParcelFee; + }; + } + + private void refundFee(Player sender, double fee) { + if (fee > 0) { + this.economy.depositPlayer(sender, fee); + } + } + @Override public CompletableFuture update(Parcel updated) { Objects.requireNonNull(updated, "Updated parcel cannot be null"); @@ -188,29 +217,57 @@ public CompletableFuture collect(Player player, Parcel parcel) { } List items = optional.get().items(); - if (items.size() > freeSlotsInInventory(player)) { - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.noInventorySpace); - return CompletableFuture.completedFuture(null); - } + CompletableFuture result = new CompletableFuture<>(); + + // Re-check inventory space on the main thread (the previous async check was a TOCTOU), + // then delete the parcel BEFORE handing the items back so it cannot be collected twice. + // Items are only given once the delete is confirmed, so a failed delete never destroys them. + this.scheduler.run(() -> { + if (!canHold(player, items)) { + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.noInventorySpace); + result.complete(null); + return; + } - return this.parcelRepository.delete(parcel) - .thenCompose(deleted -> this.parcelContentRepository.delete(parcel.uuid()) - .thenAccept(contentDeleted -> { - if (!deleted || !contentDeleted) { + this.parcelRepository.delete(parcel) + .thenCompose(deleted -> { + if (!deleted) { + return CompletableFuture.completedFuture(false); + } + // The parcel is gone, so it can never be collected again; deleting its content + // is best-effort cleanup and must not block handing the items back. Otherwise a + // failed content delete would lose the items permanently. + return this.parcelContentRepository.delete(parcel.uuid()) + .handle((contentDeleted, throwable) -> { + if (throwable != null) { + this.server.getLogger().warning("Failed to delete content for collected parcel " + + parcel.uuid() + ": " + throwable.getMessage()); + } + return true; + }); + }) + .thenAccept(removed -> { + if (!removed) { this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.databaseError); + result.complete(null); return; } - items.forEach(item -> this.scheduler.run(() -> ItemUtil.giveItem(player, item))); - this.parcelsByUuid.invalidate(parcel.uuid()); - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.collected); + this.scheduler.run(() -> { + items.forEach(item -> ItemUtil.giveItem(player, item)); + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.collected); + }); + result.complete(null); }) - ) - .exceptionally(throwable -> { - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotCollect); - return null; - }); + .exceptionally(throwable -> { + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotCollect); + result.complete(null); + return null; + }); + }); + + return result; }); } diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java index 98adac434..258c7e7dd 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java @@ -51,15 +51,13 @@ public void run() { return; } + // Delete the delivery only after the status update succeeds. If the update fails, the + // delivery row is left intact so the task is rescheduled on the next startup; deleting it + // unconditionally could strand the parcel in SENT with no delivery, never to be delivered. this.parcelService.update(updated) + .thenCompose(ignored -> this.deliveryManager.delete(updated.uuid())) .exceptionally(throwable -> { - LOGGER.severe("Failed to update parcel " + updated.uuid() + " to DELIVERED status: " + throwable.getMessage()); - return null; - }); - - this.deliveryManager.delete(updated.uuid()) - .exceptionally(throwable -> { - LOGGER.warning("Failed to delete delivery for parcel " + updated.uuid() + ": " + throwable.getMessage()); + LOGGER.severe("Failed to deliver parcel " + updated.uuid() + " (delivery left for retry): " + throwable.getMessage()); return null; }); } diff --git a/src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java b/src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java new file mode 100644 index 000000000..56bdcf66e --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java @@ -0,0 +1,31 @@ +package com.eternalcode.parcellockers.shared.event; + +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; + +/** + * Base class for the plugin's cancellable Bukkit events. Provides the cancel flag and the + * {@link Cancellable} implementation; subclasses still declare their own {@code HandlerList} + * (and static {@code getHandlerList()}) as Bukkit requires one per event type. + */ +public abstract class CancellableEvent extends Event implements Cancellable { + + private boolean cancelled; + + protected CancellableEvent() { + } + + protected CancellableEvent(boolean async) { + super(async); + } + + @Override + public boolean isCancelled() { + return this.cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + this.cancelled = cancel; + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java b/src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java index 2ffd77bbc..e6ce13a9c 100644 --- a/src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java +++ b/src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java @@ -47,7 +47,10 @@ public CompletableFuture> get(UUID uniqueId) { return CompletableFuture.completedFuture(Optional.of(user)); } - return this.userRepository.fetch(uniqueId); + return this.userRepository.fetch(uniqueId).thenApply(optional -> { + optional.ifPresent(this::cache); + return optional; + }); } @Override @@ -58,7 +61,15 @@ public CompletableFuture> get(String username) { return CompletableFuture.completedFuture(Optional.of(user)); } - return this.userRepository.fetch(username); + return this.userRepository.fetch(username).thenApply(optional -> { + optional.ifPresent(this::cache); + return optional; + }); + } + + private void cache(User user) { + this.usersByUUID.put(user.uuid(), user); + this.usersByName.put(user.name(), user); } @Override @@ -75,7 +86,16 @@ public CompletableFuture getOrCreate(UUID uuid, String name) { return CompletableFuture.completedFuture(userByName); } - return this.create(uuid, name); + // Not cached - consult the repository before creating, otherwise a user that exists in the + // database but not the cache would be created a second time. + return this.userRepository.fetch(uuid).thenCompose(optional -> { + if (optional.isPresent()) { + User user = optional.get(); + this.cache(user); + return CompletableFuture.completedFuture(user); + } + return this.create(uuid, name); + }); } @Override @@ -85,60 +105,74 @@ public CompletableFuture> getPage(Page page) { @Override public CompletableFuture create(UUID uuid, String name) { - return CompletableFuture.supplyAsync(() -> { - ValidationResult validation = this.validationService.validateCreateParameters(uuid, name); + ValidationResult validation = this.validationService.validateCreateParameters(uuid, name); - if (!validation.isValid()) { - throw new ValidationException("Invalid user parameters: " + validation.errorMessage()); - } - - Optional existingByUUID = Optional.ofNullable(this.usersByUUID.get(uuid, uniqueId -> null)); - Optional existingByName = Optional.ofNullable(this.usersByName.get(name, username -> null)); - - ValidationResult conflictCheck = this.validationService.validateNoConflicts( - uuid, name, existingByUUID, existingByName); - - if (!conflictCheck.isValid()) { - throw new ValidationException(conflictCheck.errorMessage()); - } - - User user = new User(uuid, name); - - // Fire UserCreateEvent - UserCreateEvent event = new UserCreateEvent(user); - this.server.getPluginManager().callEvent(event); - - this.usersByUUID.put(uuid, user); - this.usersByName.put(name, user); - this.userRepository.save(user); + if (!validation.isValid()) { + return CompletableFuture.failedFuture( + new ValidationException("Invalid user parameters: " + validation.errorMessage())); + } - return user; - }); + // Check for conflicts against the database, not only the cache, so a name/UUID already + // persisted but not currently cached is still detected. + return this.userRepository.fetch(uuid).thenCombine(this.userRepository.fetch(name), + (existingByUUID, existingByName) -> { + ValidationResult conflictCheck = this.validationService.validateNoConflicts( + uuid, name, existingByUUID, existingByName); + + if (!conflictCheck.isValid()) { + throw new ValidationException(conflictCheck.errorMessage()); + } + + return new User(uuid, name); + }).thenCompose(user -> { + UserCreateEvent event = new UserCreateEvent(user); + this.server.getPluginManager().callEvent(event); + + this.cache(user); + // Chain the save so a persistence failure is surfaced to the caller; if it fails, undo + // the optimistic cache entry so the cache never holds an unpersisted user. + return this.userRepository.save(user) + .whenComplete((ignored, throwable) -> { + if (throwable != null) { + this.usersByUUID.invalidate(uuid); + this.usersByName.invalidate(name); + } + }) + .thenApply(ignored -> user); + }); } @Override public CompletableFuture changeName(UUID uuid, String newName) { - return CompletableFuture.supplyAsync(() -> { - User oldUser = this.usersByUUID.getIfPresent(uuid); - - if (oldUser == null) { - throw new ValidationException("User not found with UUID: " + uuid); - } - + User cached = this.usersByUUID.getIfPresent(uuid); + CompletableFuture> lookup = cached != null + ? CompletableFuture.completedFuture(Optional.of(cached)) + : this.userRepository.fetch(uuid); + + // thenComposeAsync keeps the body (including the async UserChangeNameEvent) off the main thread + // even on a cache hit, where the lookup completes synchronously. + return lookup.thenComposeAsync(optional -> { + User oldUser = optional.orElseThrow( + () -> new ValidationException("User not found with UUID: " + uuid)); String oldName = oldUser.name(); - - // Fire UserChangeNameEvent + UserChangeNameEvent event = new UserChangeNameEvent(oldUser, oldName); this.server.getPluginManager().callEvent(event); - - // Update cache + + // Optimistically update the cache, reverting if the persist fails. User updatedUser = new User(uuid, newName); this.usersByUUID.put(uuid, updatedUser); this.usersByName.invalidate(oldName); this.usersByName.put(newName, updatedUser); - - // Update in repository - return this.userRepository.changeName(uuid, newName); - }).thenCompose(future -> future); + + return this.userRepository.changeName(uuid, newName) + .whenComplete((ignored, throwable) -> { + if (throwable != null) { + this.usersByUUID.put(uuid, oldUser); + this.usersByName.invalidate(newName); + this.usersByName.put(oldName, oldUser); + } + }); + }); } } diff --git a/src/main/java/com/eternalcode/parcellockers/user/event/UserChangeNameEvent.java b/src/main/java/com/eternalcode/parcellockers/user/event/UserChangeNameEvent.java index 972adcb9c..70d3d9d80 100644 --- a/src/main/java/com/eternalcode/parcellockers/user/event/UserChangeNameEvent.java +++ b/src/main/java/com/eternalcode/parcellockers/user/event/UserChangeNameEvent.java @@ -5,6 +5,7 @@ import org.bukkit.event.HandlerList; import org.jetbrains.annotations.NotNull; +// Fired asynchronously from the user-management futures (consistent with UserCreateEvent). public class UserChangeNameEvent extends Event { private static final HandlerList HANDLER_LIST = new HandlerList(); @@ -13,6 +14,7 @@ public class UserChangeNameEvent extends Event { private final String oldName; public UserChangeNameEvent(User user, String oldName) { + super(true); this.user = user; this.oldName = oldName; } diff --git a/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepositoryOrmLite.java index f33cea147..a21fd788b 100644 --- a/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepositoryOrmLite.java @@ -6,24 +6,15 @@ import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.user.User; -import com.j256.ormlite.table.TableUtils; -import java.sql.SQLException; -import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; public class UserRepositoryOrmLite extends AbstractRepositoryOrmLite implements UserRepository { public UserRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { super(databaseManager, scheduler); - - try { - TableUtils.createTableIfNotExists(databaseManager.connectionSource(), UserTable.class); - } catch (SQLException exception) { - exception.printStackTrace(); - } + this.createTable(UserTable.class); } @Override @@ -44,7 +35,7 @@ public CompletableFuture> fetch(String name) { @Override public CompletableFuture save(User user) { - return this.save(UserTable.class, UserTable.from(user)).exceptionally(ex -> { + return this.upsert(UserTable.class, UserTable.from(user)).exceptionally(ex -> { System.err.println("Failed to save user: " + ex.getMessage()); ex.printStackTrace(); return null; @@ -64,20 +55,6 @@ public CompletableFuture changeName(UUID uuid, String newName) { @Override public CompletableFuture> fetchPage(Page page) { - return this.action( - UserTable.class, dao -> { - List users = dao.queryBuilder() - .offset((long) page.getOffset()) - .limit((long) page.getLimit()) - .query() - .stream().map(UserTable::toUser) - .collect(Collectors.toList()); - - boolean hasNext = users.size() > page.getLimit(); - if (hasNext) { - users.removeLast(); - } - return new PageResult<>(users, hasNext); - }); + return this.queryPage(UserTable.class, page, builder -> builder, UserTable::toUser); } } diff --git a/src/main/java/com/eternalcode/parcellockers/util/InventoryUtil.java b/src/main/java/com/eternalcode/parcellockers/util/InventoryUtil.java index e469b6c0c..15cde11af 100644 --- a/src/main/java/com/eternalcode/parcellockers/util/InventoryUtil.java +++ b/src/main/java/com/eternalcode/parcellockers/util/InventoryUtil.java @@ -1,5 +1,6 @@ package com.eternalcode.parcellockers.util; +import java.util.List; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -18,4 +19,56 @@ public static int freeSlotsInInventory(Player player) { } return freeSlots; } + + /** + * Returns whether all of the given items would fit into the player's inventory, accounting for + * stacking into partially-filled slots rather than only counting fully empty slots. + */ + public static boolean canHold(Player player, List items) { + ItemStack[] contents = player.getInventory().getStorageContents(); + + // Simulate placement against a snapshot of the current slot amounts. + ItemStack[] slotType = new ItemStack[contents.length]; + int[] slotAmount = new int[contents.length]; + for (int i = 0; i < contents.length; i++) { + if (contents[i] != null) { + slotType[i] = contents[i]; + slotAmount[i] = contents[i].getAmount(); + } + } + + for (ItemStack item : items) { + if (item == null) { + continue; + } + + int remaining = item.getAmount(); + int maxStack = item.getMaxStackSize(); + + // Top up existing matching stacks first. + for (int i = 0; i < contents.length && remaining > 0; i++) { + if (slotType[i] != null && slotType[i].isSimilar(item) && slotAmount[i] < maxStack) { + int added = Math.min(maxStack - slotAmount[i], remaining); + slotAmount[i] += added; + remaining -= added; + } + } + + // Then spill into empty slots. + for (int i = 0; i < contents.length && remaining > 0; i++) { + if (slotType[i] == null) { + slotType[i] = item; + int added = Math.min(maxStack, remaining); + slotAmount[i] = added; + remaining -= added; + } + } + + if (remaining > 0) { + return false; + } + } + + return true; + } }