diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..00a8784 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,37 @@ +name: Build + +on: + push: + branches: + - master + - feature/item-stash-integration + pull_request: + branches: + - master + +permissions: + contents: read + +jobs: + test-and-package: + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: gradle + + - name: Test and package + run: ./gradlew clean test shadowJar --no-daemon + + - name: Upload plugin JAR + uses: actions/upload-artifact@v4 + with: + name: AuctionHouse-1.5.4 + path: build/libs/AuctionHouse-1.5.4.jar + if-no-files-found: error diff --git a/.gitignore b/.gitignore index d5f737e..9cf88e8 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,5 @@ runs/ # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) !gradle-wrapper.jar +!libs/ +!libs/Skyblock-Core-API-0.1.0-SNAPSHOT.jar diff --git a/build.gradle b/build.gradle index e9ee26a..818ae00 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,7 @@ version = "1.5.4" repositories { mavenCentral() + maven { url = "https://repo.papermc.io/repository/maven-public/" } maven { name = "spigotmc-repo" url = "https://hub.spigotmc.org/nexus/content/repositories/snapshots/" @@ -22,9 +23,23 @@ repositories { maven { url = "https://mvn-repo.arim.space/lesser-gpl3/" } } +def configuredSkyblockCoreApi = providers.gradleProperty("skyblockCoreApiJar") + .orElse(providers.environmentVariable("SKYBLOCK_CORE_API_JAR")) + .orNull +def workspaceSkyblockCoreApi = file("../Skyblock-Core/build/libs/Skyblock-Core-0.1.0-SNAPSHOT.jar") +def portableSkyblockCoreApi = file("libs/Skyblock-Core-API-0.1.0-SNAPSHOT.jar") +def skyblockCoreApi = configuredSkyblockCoreApi != null + ? file(configuredSkyblockCoreApi) + : (portableSkyblockCoreApi.isFile() ? portableSkyblockCoreApi : workspaceSkyblockCoreApi) + +if (!skyblockCoreApi.isFile()) { + throw new GradleException("Skyblock-Core API JAR not found. Set -PskyblockCoreApiJar= or SKYBLOCK_CORE_API_JAR.") +} + //alt + ins dependencies { - compileOnly("org.spigotmc:spigot-api:1.21.4-R0.1-SNAPSHOT") + compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") + compileOnly(files(skyblockCoreApi)) compileOnly("com.github.MilkBowl:VaultAPI:1.7") { exclude group: "org.bukkit", module: "bukkit" } @@ -33,6 +48,12 @@ dependencies { implementation("net.kyori:adventure-text-minimessage:4.25.0") implementation("net.kyori:adventure-text-serializer-legacy:4.25.0") implementation("space.arim.morepaperlib:morepaperlib:0.5.2") + + testImplementation(files(skyblockCoreApi)) + testImplementation("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") + testImplementation("org.junit.jupiter:junit-jupiter:6.0.3") + testImplementation("org.mockbukkit.mockbukkit:mockbukkit-v1.21:4.110.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:6.0.3") } tasks.named("jar") { @@ -45,10 +66,20 @@ tasks.named("shadowJar") { archiveVersion.set(version) relocate("net.kyori.adventure", "me.elaineqheart.auctionHouse.libs.adventure") relocate("space.arim.morepaperlib", "me.elaineqheart.auctionHouse.libs.morepaperlib") - destinationDirectory.set(file("/media/elaine/Boot/Elaine/programmieren/java/minecraft/MC_test_servers/1.21.5_paper/plugins")) - //destinationDirectory.set(file("/media/elaine/Boot/Elaine/programmieren/java/minecraft/MC_test_servers/26.2_spigot/plugins")) - //destinationDirectory.set(file("/media/elaine/Boot/Elaine/programmieren/java/minecraft/MC_test_servers/1.21.11_purpur/plugins")) + destinationDirectory.set(layout.buildDirectory.dir("libs")) mergeServiceFiles() + doLast { + def leakedApiClasses = zipTree(archiveFile.get().asFile).matching { + include("dev/chengzhi/skyblockcore/**") + }.files + if (!leakedApiClasses.isEmpty()) { + throw new GradleException("Skyblock-Core classes must not be bundled in AuctionHouse: ${leakedApiClasses}") + } + } +} + +tasks.named("test") { + useJUnitPlatform() } tasks { @@ -85,4 +116,4 @@ processResources { filesMatching('plugin.yml') { expand props } -} \ No newline at end of file +} diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/libs/README.md b/libs/README.md new file mode 100644 index 0000000..25208ab --- /dev/null +++ b/libs/README.md @@ -0,0 +1,12 @@ +# Skyblock-Core compile-only API + +`Skyblock-Core-API-0.1.0-SNAPSHOT.jar` contains only the seven public +`dev.chengzhi.skyblockcore.api.delivery` classes required to compile the optional +hook. They are compiled from the supplied Skyblock-Core API sources with +`--release 21`, so the AuctionHouse build remains compatible with Paper's Java 21 +baseline even when the local full Skyblock-Core JAR was built with a newer JDK. + +The dependency is `compileOnly`; `shadowJar` also fails the build if any +`dev/chengzhi/skyblockcore/**` class is found in the AuctionHouse artifact. + +SHA-256: `5D2DEA1F7006D806C98C07DF5DB6074E6C7272D5F73435D2F213296ED829A005` diff --git a/libs/Skyblock-Core-API-0.1.0-SNAPSHOT.jar b/libs/Skyblock-Core-API-0.1.0-SNAPSHOT.jar new file mode 100644 index 0000000..7049ea4 Binary files /dev/null and b/libs/Skyblock-Core-API-0.1.0-SNAPSHOT.jar differ diff --git a/src/main/java/me/elaineqheart/auctionHouse/AuctionHouse.java b/src/main/java/me/elaineqheart/auctionHouse/AuctionHouse.java index 7b2801a..c1a79c5 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/AuctionHouse.java +++ b/src/main/java/me/elaineqheart/auctionHouse/AuctionHouse.java @@ -6,10 +6,21 @@ import me.elaineqheart.auctionHouse.commands.DynamicCommandRegisterer; import me.elaineqheart.auctionHouse.data.persistentStorage.ItemNoteStorage; import me.elaineqheart.auctionHouse.data.persistentStorage.local.data.ConfigManager; +import me.elaineqheart.auctionHouse.delivery.AuctionItemDeliveryService; +import me.elaineqheart.auctionHouse.delivery.AuctionProcessingLocks; +import me.elaineqheart.auctionHouse.delivery.BukkitMainThreadExecutor; +import me.elaineqheart.auctionHouse.delivery.ItemDeliveryBootstrap; +import me.elaineqheart.auctionHouse.delivery.MainThreadExecutor; +import me.elaineqheart.auctionHouse.delivery.RetainedItemDeliveryCoordinator; import me.elaineqheart.auctionHouse.listeners.AhConfigurationListener; import me.elaineqheart.auctionHouse.listeners.PlayerJoinCollectListener; +import me.elaineqheart.auctionHouse.listeners.PurchaseSettlementRecoveryListener; import me.elaineqheart.auctionHouse.pluginDependencies.AuctionHousePAPIExpansion; import me.elaineqheart.auctionHouse.pluginDependencies.LocaleAPIExtension; +import me.elaineqheart.auctionHouse.purchase.BukkitPurchaseAuctionRepository; +import me.elaineqheart.auctionHouse.purchase.JsonPurchaseSettlementStore; +import me.elaineqheart.auctionHouse.purchase.PurchaseSettlementService; +import me.elaineqheart.auctionHouse.purchase.VaultEconomyGateway; import me.elaineqheart.auctionHouse.world.displays.DisplayKillListener; import me.elaineqheart.auctionHouse.world.displays.DisplayListener; import me.elaineqheart.auctionHouse.world.displays.UpdateDisplay; @@ -22,6 +33,8 @@ import space.arim.morepaperlib.scheduling.GracefulScheduling; import java.io.IOException; +import java.time.Clock; +import java.util.UUID; public final class AuctionHouse extends JavaPlugin { @@ -31,7 +44,15 @@ public final class AuctionHouse extends JavaPlugin { public static GUIManager getGuiManager() {return guiManager;} public static InputGUIManager getInputManager() {return inputGUIManager;} private MorePaperLib morePaperLib; + private AuctionItemDeliveryService itemDeliveryService; + private AuctionProcessingLocks processingLocks; + private RetainedItemDeliveryCoordinator retainedItemDelivery; + private PurchaseSettlementService purchaseSettlements; public static AuctionHouse getInstance() {return instance;} + public AuctionItemDeliveryService getItemDeliveryService() {return itemDeliveryService;} + public AuctionProcessingLocks getProcessingLocks() {return processingLocks;} + public RetainedItemDeliveryCoordinator getRetainedItemDelivery() {return retainedItemDelivery;} + public PurchaseSettlementService getPurchaseSettlements() {return purchaseSettlements;} public GracefulScheduling getScheduler() { //morePaperLib.scheduling()... It uses Paper's threaded-regions schedulers if Folia is used, otherwise it falls back to the default Bukkit scheduler. return morePaperLib.scheduling(); @@ -54,12 +75,6 @@ public void onEnable() { return; } - Bukkit.getPluginManager().registerEvents(new NPCListener(), this); - Bukkit.getPluginManager().registerEvents(new DisplayListener(), this); - Bukkit.getPluginManager().registerEvents(new PlayerJoinCollectListener(), this); - Bukkit.getPluginManager().registerEvents(new AhConfigurationListener(), this); - DisplayKillListener.register(); - ConfigManager.setupConfigs(); //if(SettingManager.useRedis) RedisManager.connect(); @@ -71,6 +86,37 @@ public void onEnable() { throw new RuntimeException(e); } + MainThreadExecutor mainThread = new BukkitMainThreadExecutor(this); + processingLocks = new AuctionProcessingLocks(); + itemDeliveryService = ItemDeliveryBootstrap.create(this, mainThread); + retainedItemDelivery = new RetainedItemDeliveryCoordinator( + itemDeliveryService, processingLocks, mainThread); + try { + JsonPurchaseSettlementStore settlementStore = new JsonPurchaseSettlementStore( + getDataFolder().toPath().resolve("data").resolve("purchase-settlements")); + purchaseSettlements = new PurchaseSettlementService( + settlementStore, + new BukkitPurchaseAuctionRepository(), + new VaultEconomyGateway(), + itemDeliveryService, + processingLocks, + mainThread, + UUID::randomUUID, + Clock.systemUTC(), + getLogger()); + purchaseSettlements.recover(Bukkit.getOnlinePlayers()); + } catch (IOException e) { + getLogger().severe("Failed to load AuctionHouse purchase settlements"); + throw new RuntimeException(e); + } + + Bukkit.getPluginManager().registerEvents(new NPCListener(), this); + Bukkit.getPluginManager().registerEvents(new DisplayListener(), this); + Bukkit.getPluginManager().registerEvents(new PlayerJoinCollectListener(), this); + Bukkit.getPluginManager().registerEvents(new PurchaseSettlementRecoveryListener(this), this); + Bukkit.getPluginManager().registerEvents(new AhConfigurationListener(), this); + DisplayKillListener.register(); + DynamicCommandRegisterer.init(); UpdateDisplay.init(); //NoteStorage.purge(); diff --git a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/AdminConfirmGUI.java b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/AdminConfirmGUI.java index e52bf99..5b63901 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/AdminConfirmGUI.java +++ b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/AdminConfirmGUI.java @@ -10,6 +10,9 @@ import me.elaineqheart.auctionHouse.data.ram.AuctionHouseStorage; import me.elaineqheart.auctionHouse.data.ram.ItemManager; import me.elaineqheart.auctionHouse.data.ram.ItemNote; +import me.elaineqheart.auctionHouse.delivery.DeliveryFeedback; +import me.elaineqheart.auctionHouse.delivery.RetainedDeliveryKind; +import me.elaineqheart.auctionHouse.delivery.RetainedDeliveryStatus; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; @@ -21,6 +24,7 @@ public class AdminConfirmGUI extends InventoryGUI{ private final String reason; private final boolean delete; private final AhConfiguration c; + private boolean processing; public AdminConfirmGUI(String reason, ItemNote note, boolean delete, AhConfiguration configuration) { super(); @@ -85,6 +89,11 @@ private InventoryButton confirmExpireItem() { .creator(player -> ItemManager.confirm) .consumer(event -> { Player p = (Player) event.getWhoClicked(); + if (processing) { + DeliveryFeedback.sendProcessing(p); + Sounds.villagerDeny(event); + return; + } ItemNote test = AuctionHouseStorage.getNote(note.getNoteID()); if (test == null) { p.sendMessage(M.getFormatted("chat.non-existent2")); @@ -96,8 +105,21 @@ private InventoryButton confirmExpireItem() { Sounds.villagerDeny(event); return; } - boolean success = ItemNoteStorage.adminConfirmExpireItem(note, reason); + if (!AuctionHouse.getInstance().getProcessingLocks() + .tryLock(note.getNoteID())) { + DeliveryFeedback.sendProcessing(p); + return; + } + processing = true; + boolean success; + try { + success = ItemNoteStorage.adminConfirmExpireItem(note, reason); + } finally { + AuctionHouse.getInstance().getProcessingLocks() + .unlock(note.getNoteID()); + } if (!success) { + processing = false; p.sendMessage(M.getFormatted("chat.non-existent")); Sounds.villagerDeny(event); return; @@ -114,9 +136,8 @@ private InventoryButton confirmDeleteItem() { .creator(player -> ItemManager.confirm) .consumer(event -> { Player p = (Player) event.getWhoClicked(); - //check if inventory is full - if(p.getInventory().firstEmpty() == -1) { - p.sendMessage(M.getFormatted("chat.inventory-full")); + if (processing) { + DeliveryFeedback.sendProcessing(p); Sounds.villagerDeny(event); return; } @@ -133,18 +154,46 @@ private InventoryButton confirmDeleteItem() { return; } ItemStack item = note.getItem(); - boolean success = ItemNoteStorage.adminConfirmDeleteItem(note, reason); - if (!success) { - p.sendMessage(M.getFormatted("chat.non-existent")); - Sounds.villagerDeny(event); - return; - } - - p.getInventory().addItem(item); - p.closeInventory(); - Sounds.experience(event); - Sounds.breakWood(event); - p.sendMessage(M.getFormatted("chat.admin-delete-auction","%reason%", reason)); + processing = true; + AuctionHouse.getInstance().getRetainedItemDelivery().deliver( + p, + note.getNoteID(), + RetainedDeliveryKind.ADMIN_RETURN, + item, + java.util.Map.of("seller_uuid", note.getPlayerUUID().toString()), + () -> { + if (!ItemNoteStorage.adminConfirmDeleteItem(note, reason)) { + throw new IllegalStateException("Admin return source changed"); + } + } + ).whenComplete((result, failure) -> { + if (failure != null || result == null + || result.status() == RetainedDeliveryStatus.COMMIT_FAILED) { + processing = false; + DeliveryFeedback.sendReconciliationRequired(p); + AuctionHouse.getInstance().getLogger().warning( + "Failed to finalize admin return " + note.getNoteID()); + return; + } + if (result.status() == RetainedDeliveryStatus.PROCESSING) { + processing = false; + DeliveryFeedback.sendProcessing(p); + Sounds.villagerDeny(event); + return; + } + if (result.status() == RetainedDeliveryStatus.DELIVERY_FAILED) { + processing = false; + DeliveryFeedback.sendFailure(p, result.delivery()); + Sounds.villagerDeny(event); + return; + } + DeliveryFeedback.sendDestination(p, result.delivery()); + p.closeInventory(); + Sounds.experience(event); + Sounds.breakWood(event); + p.sendMessage(M.getFormatted("chat.admin-delete-auction", + "%reason%", reason)); + }); }); } private InventoryButton cancel(){ diff --git a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/AuctionViewGUI.java b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/AuctionViewGUI.java index 51e859b..78d3a19 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/AuctionViewGUI.java +++ b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/AuctionViewGUI.java @@ -11,9 +11,13 @@ import me.elaineqheart.auctionHouse.data.persistentStorage.local.SettingManager; import me.elaineqheart.auctionHouse.data.persistentStorage.local.configs.M; import me.elaineqheart.auctionHouse.data.ram.AhConfiguration; +import me.elaineqheart.auctionHouse.data.ram.AuctionHouseStorage; import me.elaineqheart.auctionHouse.data.ram.Bid; import me.elaineqheart.auctionHouse.data.ram.ItemManager; import me.elaineqheart.auctionHouse.data.ram.ItemNote; +import me.elaineqheart.auctionHouse.delivery.DeliveryFeedback; +import me.elaineqheart.auctionHouse.delivery.RetainedDeliveryKind; +import me.elaineqheart.auctionHouse.delivery.RetainedDeliveryStatus; import me.elaineqheart.auctionHouse.pluginDependencies.VaultHook; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -33,6 +37,7 @@ public class AuctionViewGUI extends InventoryGUI implements Runnable{ private final AhConfiguration c; private double bid; private boolean topBid; + private boolean cancelProcessing; private final AhConfiguration.View goBackTo; public static Map currentGUIs = new HashMap<>(); @@ -289,23 +294,53 @@ private InventoryButton cancelAuction() { .creator(player -> ItemManager.cancelAuction) .consumer(event -> { Player p = (Player) event.getWhoClicked(); - //check if inventory is full - if(p.getInventory().firstEmpty() == -1){ - p.sendMessage(M.getFormatted("chat.inventory-full")); + if (cancelProcessing) { + DeliveryFeedback.sendProcessing(p); Sounds.villagerDeny(event); return; } - if (note.hasBidHistory()) { + ItemNote current = AuctionHouseStorage.getNote(note.getNoteID()); + if (current == null || current.hasBidHistory() + || !current.getPlayerUUID().equals(p.getUniqueId())) { p.sendMessage(M.getFormatted("chat.already-sold3")); Sounds.villagerDeny(event); return; } - Sounds.experience(event); - Sounds.breakWood(event); - p.getInventory().addItem(note.getItem()); - ItemNoteStorage.deleteCancelNote(note); - AuctionHouse.getGuiManager().openGUI(p, c, goBackTo); - p.sendMessage(M.getFormatted("chat.auction-canceled")); + cancelProcessing = true; + instance.getRetainedItemDelivery().deliver( + p, + current.getNoteID(), + RetainedDeliveryKind.CANCEL, + current.getItem(), + Map.of("owner_uuid", current.getPlayerUUID().toString()), + () -> ItemNoteStorage.deleteCancelNote(current) + ).whenComplete((result, failure) -> { + if (failure != null || result == null + || result.status() == RetainedDeliveryStatus.COMMIT_FAILED) { + cancelProcessing = false; + DeliveryFeedback.sendReconciliationRequired(p); + instance.getLogger().warning("Failed to finalize cancellation for " + + current.getNoteID()); + return; + } + if (result.status() == RetainedDeliveryStatus.PROCESSING) { + cancelProcessing = false; + DeliveryFeedback.sendProcessing(p); + Sounds.villagerDeny(event); + return; + } + if (result.status() == RetainedDeliveryStatus.DELIVERY_FAILED) { + cancelProcessing = false; + DeliveryFeedback.sendFailure(p, result.delivery()); + Sounds.villagerDeny(event); + return; + } + DeliveryFeedback.sendDestination(p, result.delivery()); + Sounds.experience(event); + Sounds.breakWood(event); + AuctionHouse.getGuiManager().openGUI(p, c, goBackTo); + p.sendMessage(M.getFormatted("chat.auction-canceled")); + }); }); } diff --git a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CancelAuctionGUI.java b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CancelAuctionGUI.java index c09e55a..ae714d2 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CancelAuctionGUI.java +++ b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CancelAuctionGUI.java @@ -8,8 +8,12 @@ import me.elaineqheart.auctionHouse.data.persistentStorage.ItemNoteStorage; import me.elaineqheart.auctionHouse.data.persistentStorage.local.configs.M; import me.elaineqheart.auctionHouse.data.ram.AhConfiguration; +import me.elaineqheart.auctionHouse.data.ram.AuctionHouseStorage; import me.elaineqheart.auctionHouse.data.ram.ItemManager; import me.elaineqheart.auctionHouse.data.ram.ItemNote; +import me.elaineqheart.auctionHouse.delivery.DeliveryFeedback; +import me.elaineqheart.auctionHouse.delivery.RetainedDeliveryKind; +import me.elaineqheart.auctionHouse.delivery.RetainedDeliveryStatus; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; @@ -20,6 +24,7 @@ public class CancelAuctionGUI extends InventoryGUI implements Runnable{ private final ItemNote note; private final AhConfiguration c; private final AhConfiguration.View goBackTo; + private boolean processing; private static final AuctionHouse instance = AuctionHouse.getInstance(); @@ -104,24 +109,53 @@ private InventoryButton collectItem() { .creator(player -> ItemManager.cancelBINAuction) .consumer(event -> { Player p = (Player) event.getWhoClicked(); - //check if inventory is full - if(p.getInventory().firstEmpty() == -1){ - p.sendMessage(M.getFormatted("chat.inventory-full")); + if (processing) { + DeliveryFeedback.sendProcessing(p); Sounds.villagerDeny(event); return; } - //ItemNote test = NoteStorage.getNote(note.getNoteID().toString()); - if (note.isSold()) { + ItemNote current = AuctionHouseStorage.getNote(note.getNoteID()); + if (current == null || current.isSold() + || !current.getPlayerUUID().equals(p.getUniqueId())) { p.sendMessage(M.getFormatted("chat.already-sold2")); Sounds.villagerDeny(event); return; } - Sounds.experience(event); - Sounds.breakWood(event); - p.getInventory().addItem(note.getItem()); - ItemNoteStorage.deleteCancelNote(note); - AuctionHouse.getGuiManager().openGUI(p, c, goBackTo); - p.sendMessage(M.getFormatted("chat.auction-canceled")); + processing = true; + instance.getRetainedItemDelivery().deliver( + p, + current.getNoteID(), + RetainedDeliveryKind.CANCEL, + current.getItem(), + java.util.Map.of("owner_uuid", current.getPlayerUUID().toString()), + () -> ItemNoteStorage.deleteCancelNote(current) + ).whenComplete((result, failure) -> { + if (failure != null || result == null + || result.status() == RetainedDeliveryStatus.COMMIT_FAILED) { + processing = false; + DeliveryFeedback.sendReconciliationRequired(p); + instance.getLogger().warning("Failed to finalize cancellation for " + + current.getNoteID()); + return; + } + if (result.status() == RetainedDeliveryStatus.PROCESSING) { + processing = false; + DeliveryFeedback.sendProcessing(p); + Sounds.villagerDeny(event); + return; + } + if (result.status() == RetainedDeliveryStatus.DELIVERY_FAILED) { + processing = false; + DeliveryFeedback.sendFailure(p, result.delivery()); + Sounds.villagerDeny(event); + return; + } + DeliveryFeedback.sendDestination(p, result.delivery()); + Sounds.experience(event); + Sounds.breakWood(event); + AuctionHouse.getGuiManager().openGUI(p, c, goBackTo); + p.sendMessage(M.getFormatted("chat.auction-canceled")); + }); }); } diff --git a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CollectExpiredItemGUI.java b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CollectExpiredItemGUI.java index 1014914..e0a9d4d 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CollectExpiredItemGUI.java +++ b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CollectExpiredItemGUI.java @@ -7,8 +7,12 @@ import me.elaineqheart.auctionHouse.data.persistentStorage.ItemNoteStorage; import me.elaineqheart.auctionHouse.data.persistentStorage.local.configs.M; import me.elaineqheart.auctionHouse.data.ram.AhConfiguration; +import me.elaineqheart.auctionHouse.data.ram.AuctionHouseStorage; import me.elaineqheart.auctionHouse.data.ram.ItemManager; import me.elaineqheart.auctionHouse.data.ram.ItemNote; +import me.elaineqheart.auctionHouse.delivery.DeliveryFeedback; +import me.elaineqheart.auctionHouse.delivery.RetainedDeliveryKind; +import me.elaineqheart.auctionHouse.delivery.RetainedDeliveryStatus; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; @@ -18,6 +22,7 @@ public class CollectExpiredItemGUI extends InventoryGUI { private final ItemNote note; private final AhConfiguration c; + private boolean processing; public CollectExpiredItemGUI(ItemNote note, AhConfiguration configuration) { super(); this.note = note; @@ -80,48 +85,90 @@ private InventoryButton collectItem() { .creator(player -> ItemManager.collectExpiredItem) .consumer(event -> { Player p = (Player) event.getWhoClicked(); - //check if inventory is full - if(p.getInventory().firstEmpty() == -1){ - p.sendMessage(M.getFormatted("chat.inventory-full")); + if (processing) { + DeliveryFeedback.sendProcessing(p); Sounds.villagerDeny(event); return; } - - ItemStack withdrawItem = note.getItem(); - - boolean collected; - if(note.getAdminMessage() != null && !note.getAdminMessage().isEmpty()) { // expired by a moderator - if (note.getItem().equals(ItemManager.createDirt())) { - collected = ItemNoteStorage.collectAdminDeletedAuctionItem(note); - } else { - collected = ItemNoteStorage.collectAdminExpiredAuctionItem(note); - } - } else { - collected = ItemNoteStorage.collectExpiredAuctionItem(note); // delete it first!! - } - if (!collected) { + ItemNote current = AuctionHouseStorage.getNote(note.getNoteID()); + if (current == null || !current.getPlayerUUID().equals(p.getUniqueId())) { p.sendMessage(M.getFormatted("chat.non-existent")); Sounds.villagerDeny(event); return; } - - if(note.getAdminMessage() != null && !note.getAdminMessage().isEmpty()) { // expired by a moderator - if(note.getItem().equals(ItemManager.createDirt())) { - p.sendMessage(M.getFormatted("chat.deleted-auction-by-admin", "%reason%", note.getAdminMessage())); + boolean administered = current.getAdminMessage() != null + && !current.getAdminMessage().isEmpty(); + boolean deletedByAdmin = administered + && current.getItem().equals(ItemManager.createDirt()); + if (deletedByAdmin) { + if (!AuctionHouse.getInstance().getProcessingLocks() + .tryLock(current.getNoteID())) { + DeliveryFeedback.sendProcessing(p); + return; + } + try { + processing = true; + ItemNoteStorage.collectAdminDeletedAuctionItem(current); + p.sendMessage(M.getFormatted("chat.deleted-auction-by-admin", + "%reason%", current.getAdminMessage())); p.closeInventory(); Sounds.breakWood(event); - }else { - p.sendMessage(M.getFormatted("chat.expired-auction-by-admin", "%reason%", note.getAdminMessage())); - p.closeInventory(); - p.getInventory().addItem(withdrawItem); - Sounds.experience(event); + } finally { + AuctionHouse.getInstance().getProcessingLocks() + .unlock(current.getNoteID()); } - } else { - AuctionHouse.getGuiManager().openGUI(new MyAuctionsGUI(c), p); - p.getInventory().addItem(withdrawItem); - Sounds.experience(event); + return; } + ItemStack withdrawItem = current.getItem(); + RetainedDeliveryKind kind = administered + ? RetainedDeliveryKind.ADMIN_EXPIRED + : RetainedDeliveryKind.EXPIRED; + processing = true; + AuctionHouse.getInstance().getRetainedItemDelivery().deliver( + p, + current.getNoteID(), + kind, + withdrawItem, + java.util.Map.of("owner_uuid", current.getPlayerUUID().toString()), + () -> { + if (administered) { + ItemNoteStorage.collectAdminExpiredAuctionItem(current); + } else { + ItemNoteStorage.collectExpiredAuctionItem(current); + } + } + ).whenComplete((result, failure) -> { + if (failure != null || result == null + || result.status() == RetainedDeliveryStatus.COMMIT_FAILED) { + processing = false; + DeliveryFeedback.sendReconciliationRequired(p); + AuctionHouse.getInstance().getLogger().warning( + "Failed to finalize expired item " + current.getNoteID()); + return; + } + if (result.status() == RetainedDeliveryStatus.PROCESSING) { + processing = false; + DeliveryFeedback.sendProcessing(p); + Sounds.villagerDeny(event); + return; + } + if (result.status() == RetainedDeliveryStatus.DELIVERY_FAILED) { + processing = false; + DeliveryFeedback.sendFailure(p, result.delivery()); + Sounds.villagerDeny(event); + return; + } + DeliveryFeedback.sendDestination(p, result.delivery()); + if (administered) { + p.sendMessage(M.getFormatted("chat.expired-auction-by-admin", + "%reason%", current.getAdminMessage())); + p.closeInventory(); + } else { + AuctionHouse.getGuiManager().openGUI(new MyAuctionsGUI(c), p); + } + Sounds.experience(event); + }); }); } diff --git a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CollectSoldItemGUI.java b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CollectSoldItemGUI.java index 73f5224..263acca 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CollectSoldItemGUI.java +++ b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/CollectSoldItemGUI.java @@ -11,6 +11,7 @@ import me.elaineqheart.auctionHouse.data.ram.AuctionHouseStorage; import me.elaineqheart.auctionHouse.data.ram.ItemManager; import me.elaineqheart.auctionHouse.data.ram.ItemNote; +import me.elaineqheart.auctionHouse.delivery.DeliveryFeedback; import me.elaineqheart.auctionHouse.pluginDependencies.VaultHook; import net.milkbowl.vault.economy.Economy; import org.bukkit.Bukkit; @@ -109,6 +110,10 @@ private InventoryButton collectItem() { } public static boolean collect(OfflinePlayer p, UUID noteID, int itemAmount, double price) { + if (AuctionHouse.getInstance().getProcessingLocks().isLocked(noteID)) { + if (p instanceof Player onlinePlayer) DeliveryFeedback.sendProcessing(onlinePlayer); + return false; + } ItemNote note = AuctionHouseStorage.getNote(noteID); boolean success = ItemNoteStorage.collectSoldAuctionItem(note, itemAmount, price); if (!success) { diff --git a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/ConfirmBidGUI.java b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/ConfirmBidGUI.java index 9cd8af7..28319df 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/ConfirmBidGUI.java +++ b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/ConfirmBidGUI.java @@ -119,7 +119,8 @@ private InventoryButton confirm(){ p.sendMessage(M.getFormatted("chat.placed-bid", price, "%item%", note.getItemName())); if (c.shouldKeepOpen()) AuctionHouse.getGuiManager().openGUI(new AuctionViewGUI(note, c, 0, goBackToAuctionHouse ? AhConfiguration.View.AUCTION_HOUSE : AhConfiguration.View.MY_AUCTIONS), p); - else instance.getScheduler().globalRegionalScheduler().run(p::closeInventory); + else instance.getScheduler().globalRegionalScheduler().run( + (Runnable) p::closeInventory); Set bidders = note.getBidders(); bidders.remove(p.getUniqueId()); diff --git a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/ConfirmBuyGUI.java b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/ConfirmBuyGUI.java index a5f0dc6..5b42f86 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/ConfirmBuyGUI.java +++ b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/ConfirmBuyGUI.java @@ -4,17 +4,17 @@ import me.elaineqheart.auctionHouse.GUI.InventoryButton; import me.elaineqheart.auctionHouse.GUI.InventoryGUI; import me.elaineqheart.auctionHouse.GUI.other.Sounds; -import me.elaineqheart.auctionHouse.data.persistentStorage.ItemNoteStorage; import me.elaineqheart.auctionHouse.data.persistentStorage.local.SettingManager; import me.elaineqheart.auctionHouse.data.persistentStorage.local.configs.M; +import me.elaineqheart.auctionHouse.delivery.DeliveryFeedback; import me.elaineqheart.auctionHouse.data.ram.AhConfiguration; import me.elaineqheart.auctionHouse.data.ram.AuctionHouseStorage; import me.elaineqheart.auctionHouse.data.ram.ItemManager; import me.elaineqheart.auctionHouse.data.ram.ItemNote; -import me.elaineqheart.auctionHouse.pluginDependencies.VaultHook; +import me.elaineqheart.auctionHouse.purchase.PurchaseResult; +import me.elaineqheart.auctionHouse.purchase.PurchaseResultStatus; import net.md_5.bungee.api.chat.ClickEvent; import net.md_5.bungee.api.chat.TextComponent; -import net.milkbowl.vault.economy.Economy; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; @@ -26,6 +26,7 @@ public class ConfirmBuyGUI extends InventoryGUI{ private final ItemStack item; private final AhConfiguration c; private final double price; + private boolean processing; private static final AuctionHouse instance = AuctionHouse.getInstance(); public ConfirmBuyGUI(ItemNote note, AhConfiguration configuration, ItemStack item) { @@ -79,13 +80,11 @@ private InventoryButton confirm(){ .creator(player -> ItemManager.createConfirm(price)) .consumer(event -> { Player p = (Player) event.getWhoClicked(); - //check if inventory is full - if(p.getInventory().firstEmpty() == -1) { - p.sendMessage(M.getFormatted("chat.inventory-full")); + if (processing) { + DeliveryFeedback.sendProcessing(p); Sounds.villagerDeny(event); return; } - ItemNote test = AuctionHouseStorage.getNote(note.getNoteID()); if (test == null) { p.sendMessage(M.getFormatted("chat.non-existent2")); @@ -102,54 +101,98 @@ private InventoryButton confirm(){ Sounds.villagerDeny(event); return; } - Economy eco = VaultHook.getEconomy(); - instance.getScheduler().globalRegionalScheduler().run(() -> AuctionHouse.getGuiManager().openGUI(new AuctionHouseGUI(c), p)); - if (eco.getBalance(p) < price) { //extra check to make sure that they have enough coins - p.sendMessage(M.getFormatted("chat.not-enough-money")); - Sounds.villagerDeny(event); - return; - } - //concurrent check - boolean claimed = ItemNoteStorage.setSoldIfOnAuction(note, p, item.getAmount(), price); - if (!claimed) { - p.sendMessage(M.getFormatted("chat.already-sold")); - Sounds.villagerDeny(event); - return; - } + processing = true; + p.closeInventory(); + instance.getPurchaseSettlements() + .purchase(p, note.getNoteID(), item.getAmount()) + .whenComplete((result, failure) -> handlePurchaseResult( + p, event, result, failure)); + }); + } - eco.withdrawPlayer(p, price); - Sounds.experience(event); - p.getInventory().addItem(item); + private void handlePurchaseResult(Player buyer, + org.bukkit.event.inventory.InventoryClickEvent event, + PurchaseResult result, + Throwable failure) { + if (failure != null || result == null) { + DeliveryFeedback.sendReconciliationRequired(buyer); + Sounds.villagerDeny(event); + return; + } + switch (result.status()) { + case PROCESSING -> { + DeliveryFeedback.sendProcessing(buyer); + Sounds.villagerDeny(event); + } + case NOT_AVAILABLE -> { + buyer.sendMessage(M.getFormatted("chat.already-sold2")); + Sounds.villagerDeny(event); + } + case INSUFFICIENT_FUNDS -> { + buyer.sendMessage(M.getFormatted("chat.not-enough-money")); + Sounds.villagerDeny(event); + } + case PAYMENT_DECLINED -> { + buyer.sendMessage(M.getFormatted("chat.item-delivery.payment-failed")); + Sounds.villagerDeny(event); + } + case DELIVERY_FAILED -> { + DeliveryFeedback.sendFailure(buyer, result.delivery()); + Sounds.villagerDeny(event); + } + case RECONCILIATION_REQUIRED -> { + DeliveryFeedback.sendReconciliationRequired(buyer); + Sounds.villagerDeny(event); + } + case FAILED -> { + buyer.sendMessage(M.getFormatted("chat.item-delivery.preserved")); + Sounds.villagerDeny(event); + } + case COMMITTED -> purchaseCommitted(buyer, event, result); + } + if (result.status() != PurchaseResultStatus.RECONCILIATION_REQUIRED) { + AuctionHouse.getGuiManager().openGUI(new AuctionHouseGUI(c), buyer); + } + } - p.sendMessage(M.getFormatted("chat.purchase-auction", - "%seller%", M.formatSeller(note.getPlayerName(), note.getPlayerUUID()), - "%item%", note.getItemName())); - Player seller = Bukkit.getPlayer(note.getPlayerUUID()); - if (SettingManager.soldMessageEnabled && seller != null && Bukkit.getOnlinePlayers().contains(seller)) { - String itemName = note.getItemName(); - String amount = String.valueOf(item.getAmount()); - String buyer = M.formatBuyer(p.getDisplayName(), p.getUniqueId()); - if(SettingManager.autoCollect) { - seller.sendMessage(M.getFormatted("chat.sold-message.auto-collect", price, - "%buyer%", buyer, - "%item%", itemName, - "%amount%", amount)); - } else { - TextComponent component = new TextComponent(M.getFormatted("chat.sold-message.prefix", price, - "%buyer%", buyer, - "%item%", itemName, - "%amount%", amount)); - TextComponent click = new TextComponent(M.getFormatted("chat.sold-message.interaction")); - click.setClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/ah view " + note.getNoteID().toString())); - seller.spigot().sendMessage(component, click); - } - } - if (SettingManager.autoCollect && Bukkit.getPlayer(note.getPlayerUUID()) != null) { - instance.getScheduler().globalRegionalScheduler().run(() -> CollectSoldItemGUI.collect - (Bukkit.getOfflinePlayer(note.getPlayerUUID()), note.getNoteID(), item.getAmount(), note.getSoldPrice()) - ); - } - }); + private void purchaseCommitted(Player buyer, + org.bukkit.event.inventory.InventoryClickEvent event, + PurchaseResult result) { + DeliveryFeedback.sendDestination(buyer, result.delivery()); + Sounds.experience(event); + double settledPrice = result.settlement().amount(); + int settledQuantity = result.settlement().quantity(); + buyer.sendMessage(M.getFormatted("chat.purchase-auction", + "%seller%", M.formatSeller(note.getPlayerName(), note.getPlayerUUID()), + "%item%", note.getItemName())); + Player seller = Bukkit.getPlayer(note.getPlayerUUID()); + if (SettingManager.soldMessageEnabled && seller != null && seller.isOnline()) { + String itemName = note.getItemName(); + String amount = String.valueOf(settledQuantity); + String formattedBuyer = M.formatBuyer(buyer.getDisplayName(), buyer.getUniqueId()); + if (SettingManager.autoCollect) { + seller.sendMessage(M.getFormatted("chat.sold-message.auto-collect", settledPrice, + "%buyer%", formattedBuyer, + "%item%", itemName, + "%amount%", amount)); + } else { + TextComponent component = new TextComponent(M.getFormatted( + "chat.sold-message.prefix", settledPrice, + "%buyer%", formattedBuyer, + "%item%", itemName, + "%amount%", amount)); + TextComponent click = new TextComponent( + M.getFormatted("chat.sold-message.interaction")); + click.setClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, + "/ah view " + note.getNoteID())); + seller.spigot().sendMessage(component, click); + } + } + if (SettingManager.autoCollect && seller != null) { + instance.getScheduler().globalRegionalScheduler().run(() -> + CollectSoldItemGUI.collect(Bukkit.getOfflinePlayer(note.getPlayerUUID()), + note.getNoteID(), settledQuantity, settledPrice)); + } } private InventoryButton cancel(){ return new InventoryButton() diff --git a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/EndedAuctionGUI.java b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/EndedAuctionGUI.java index c24c12a..ce6a9e3 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/EndedAuctionGUI.java +++ b/src/main/java/me/elaineqheart/auctionHouse/GUI/impl/EndedAuctionGUI.java @@ -12,6 +12,9 @@ import me.elaineqheart.auctionHouse.data.ram.AuctionHouseStorage; import me.elaineqheart.auctionHouse.data.ram.ItemManager; import me.elaineqheart.auctionHouse.data.ram.ItemNote; +import me.elaineqheart.auctionHouse.delivery.DeliveryFeedback; +import me.elaineqheart.auctionHouse.delivery.RetainedDeliveryKind; +import me.elaineqheart.auctionHouse.delivery.RetainedDeliveryStatus; import me.elaineqheart.auctionHouse.pluginDependencies.VaultHook; import net.milkbowl.vault.economy.Economy; import org.bukkit.Bukkit; @@ -28,6 +31,7 @@ public class EndedAuctionGUI extends InventoryGUI implements Runnable{ private final ItemNote note; private final AhConfiguration c; private final boolean topBid; + private boolean claimProcessing; private final AhConfiguration.View goBackTo; @Override @@ -125,40 +129,68 @@ private InventoryButton collectItem() { .creator(player -> ItemManager.createCollectAuction(note)) .consumer(event -> { Player p = (Player) event.getWhoClicked(); - if(p.getInventory().firstEmpty() == -1) { - p.sendMessage(M.getFormatted("chat.inventory-full")); + if (claimProcessing) { + DeliveryFeedback.sendProcessing(p); Sounds.villagerDeny(event); return; } ItemNote test = AuctionHouseStorage.getNote(note.getNoteID()); - if (test == null) { + if (test == null || !Objects.equals(test.getLastBidder(), p.getUniqueId()) + || !test.canClaimBid(p.getUniqueId())) { p.sendMessage(M.getFormatted("chat.non-existent2")); Sounds.villagerDeny(event); return; } - ItemStack item = note.getItem(); + ItemStack item = test.getItem(); String message = M.getFormatted("chat.claim-auction", - "%item%", note.getItemName(), - "%seller%", M.formatSeller(note.getPlayerName(), note.getPlayerUUID())); - - boolean claimed = ItemNoteStorage.claimEndedAuctionItem(p, note); - if (!claimed) { - p.sendMessage(M.getFormatted("chat.non-existent")); - Sounds.villagerDeny(event); - return; - } - - p.getInventory().addItem(item); - p.sendMessage(message); - Sounds.experience(event); - openGUI(p); - ConfigManager.transactionLogger.logTransaction( - p.getDisplayName(), - note.getPlayerName(), - note.getItemName(), - note.getPrice(), - item.getAmount(), - note.isBIDAuction()); + "%item%", test.getItemName(), + "%seller%", M.formatSeller(test.getPlayerName(), test.getPlayerUUID())); + + claimProcessing = true; + instance.getRetainedItemDelivery().deliver( + p, + test.getNoteID(), + RetainedDeliveryKind.BID_WIN, + item, + java.util.Map.of("seller_uuid", test.getPlayerUUID().toString()), + () -> { + if (!ItemNoteStorage.claimEndedAuctionItem(p, test)) { + throw new IllegalStateException("Bid claim source changed"); + } + } + ).whenComplete((result, failure) -> { + if (failure != null || result == null + || result.status() == RetainedDeliveryStatus.COMMIT_FAILED) { + claimProcessing = false; + DeliveryFeedback.sendReconciliationRequired(p); + instance.getLogger().warning("Failed to finalize winning bid " + + test.getNoteID()); + return; + } + if (result.status() == RetainedDeliveryStatus.PROCESSING) { + claimProcessing = false; + DeliveryFeedback.sendProcessing(p); + Sounds.villagerDeny(event); + return; + } + if (result.status() == RetainedDeliveryStatus.DELIVERY_FAILED) { + claimProcessing = false; + DeliveryFeedback.sendFailure(p, result.delivery()); + Sounds.villagerDeny(event); + return; + } + DeliveryFeedback.sendDestination(p, result.delivery()); + p.sendMessage(message); + Sounds.experience(event); + openGUI(p); + ConfigManager.transactionLogger.logTransaction( + p.getDisplayName(), + test.getPlayerName(), + test.getItemName(), + test.getPrice(), + item.getAmount(), + test.isBIDAuction()); + }); }); } private InventoryButton collectCoins() { diff --git a/src/main/java/me/elaineqheart/auctionHouse/data/persistentStorage/ItemNoteStorage.java b/src/main/java/me/elaineqheart/auctionHouse/data/persistentStorage/ItemNoteStorage.java index 9a3c839..540eb2c 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/data/persistentStorage/ItemNoteStorage.java +++ b/src/main/java/me/elaineqheart/auctionHouse/data/persistentStorage/ItemNoteStorage.java @@ -181,6 +181,60 @@ public static boolean setSoldIfOnAuction(ItemNote note, Player p, int amount, do return true; } + public static PurchaseCommitResult commitPurchase(ItemNote suppliedNote, + UUID settlementId, + String buyerName, + UUID buyerId, + int quantity, + double price) { + ItemNote note = AuctionHouseStorage.getNote(suppliedNote.getNoteID()); + if (note == null) return PurchaseCommitResult.SOURCE_MISSING; + if (note.hasCommittedPurchase(settlementId)) { + return PurchaseCommitResult.ALREADY_COMMITTED; + } + if (note.isBIDAuction() || note.isExpired() || !note.isTheoreticallyOnAuction() + || quantity <= 0 || note.getCurrentAmount() < quantity) { + return PurchaseCommitResult.CONFLICT; + } + + int remaining = note.getCurrentAmount() - quantity; + note.setSold(true); + note.setBuyerName(buyerName, buyerId); + note.setPartiallySoldAmountLeft(remaining); + note.markPurchaseCommitted(settlementId); + try { + saveNotes(); + } catch (IOException persistenceFailure) { + restorePersistedNotes(persistenceFailure); + throw new RuntimeException("Failed to persist purchase settlement " + settlementId, + persistenceFailure); + } + ConfigManager.transactionLogger.logTransaction( + buyerName, + note.getPlayerName(), + note.getItemName(), + price, + quantity, + note.isBIDAuction()); + return PurchaseCommitResult.COMMITTED; + } + + private static void restorePersistedNotes(IOException originalFailure) { + try { + reload(); + loadNotes(); + } catch (IOException recoveryFailure) { + originalFailure.addSuppressed(recoveryFailure); + } + } + + public enum PurchaseCommitResult { + COMMITTED, + ALREADY_COMMITTED, + SOURCE_MISSING, + CONFLICT + } + public static boolean collectExpiredAuctionItem(ItemNote note) { deleteExpiredNote(note); saveNotesWithoutCheck(); diff --git a/src/main/java/me/elaineqheart/auctionHouse/data/persistentStorage/local/data/JsonNoteStorage.java b/src/main/java/me/elaineqheart/auctionHouse/data/persistentStorage/local/data/JsonNoteStorage.java index 7df485a..b9d3112 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/data/persistentStorage/local/data/JsonNoteStorage.java +++ b/src/main/java/me/elaineqheart/auctionHouse/data/persistentStorage/local/data/JsonNoteStorage.java @@ -8,6 +8,7 @@ import java.io.*; import java.nio.file.Files; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.StandardCopyOption; public class JsonNoteStorage { @@ -23,6 +24,7 @@ public static void createNote(ItemNote note){ try { saveNotes(); } catch (IOException e) { + AuctionHouseStorage.remove(note); throw new RuntimeException(e); } } @@ -33,6 +35,7 @@ public static void deleteNote(ItemNote note) { try { saveNotes(); } catch (IOException e) { + AuctionHouseStorage.add(note); throw new RuntimeException(e); } } @@ -40,15 +43,21 @@ public static void deleteNote(ItemNote note) { public static void saveNotes() throws IOException { File file = new File(AuctionHouse.getInstance().getDataFolder().getAbsolutePath() + "/data/notes.json"); - //if the parent file of the plugin doesn't exist, it has to be created - file.getParentFile().mkdir(); - file.createNewFile(); - //if append is true, it will append the json text and not overwrite the file - Writer writer = new FileWriter(file, false); - getGson().toJson(AuctionHouseStorage.getAll(), writer); - //flush = write data - writer.flush(); - writer.close(); + File temporary = new File(file.getAbsolutePath() + ".tmp"); + Files.createDirectories(file.getParentFile().toPath()); + try (FileOutputStream output = new FileOutputStream(temporary, false); + Writer writer = new OutputStreamWriter(output, java.nio.charset.StandardCharsets.UTF_8)) { + getGson().toJson(AuctionHouseStorage.getAll(), writer); + writer.flush(); + output.getFD().sync(); + } + try { + Files.move(temporary.toPath(), file.toPath(), + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temporary.toPath(), file.toPath(), + StandardCopyOption.REPLACE_EXISTING); + } } @@ -56,9 +65,11 @@ public static void loadNotes() throws IOException { if (ConfigManager.backwardsCompatibility()) backwardsCompatibility(); File file = new File(AuctionHouse.getInstance().getDataFolder().getAbsolutePath() + "/data/notes.json"); if(file.exists()){ - Reader reader = new FileReader(file); - ItemNote[] items = getGson().fromJson(reader, ItemNote[].class); - AuctionHouseStorage.set(items); + try (Reader reader = new InputStreamReader( + new FileInputStream(file), java.nio.charset.StandardCharsets.UTF_8)) { + ItemNote[] items = getGson().fromJson(reader, ItemNote[].class); + AuctionHouseStorage.set(items == null ? new ItemNote[0] : items); + } } } diff --git a/src/main/java/me/elaineqheart/auctionHouse/data/ram/ItemNote.java b/src/main/java/me/elaineqheart/auctionHouse/data/ram/ItemNote.java index ec51c63..f2da5d8 100644 --- a/src/main/java/me/elaineqheart/auctionHouse/data/ram/ItemNote.java +++ b/src/main/java/me/elaineqheart/auctionHouse/data/ram/ItemNote.java @@ -40,6 +40,7 @@ public class ItemNote { private final boolean isBIDAuction; private List bidHistory = new ArrayList<>(); private Set claimedPlayers = new HashSet<>(); + private Set committedPurchaseSettlements = new HashSet<>(); public ItemNote(Player player, ItemStack item, double price, boolean isBIDAuction) { this.noteID = UUID.randomUUID(); @@ -173,6 +174,13 @@ public Set getClaimedPlayers() { return claimedPlayers; } public boolean canClaimBid(UUID playerID) {return !getClaimedPlayers().contains(playerID);} + public Set getCommittedPurchaseSettlements() { + if (committedPurchaseSettlements == null) committedPurchaseSettlements = new HashSet<>(); + return committedPurchaseSettlements; + } + public boolean hasCommittedPurchase(UUID settlementId) { + return getCommittedPurchaseSettlements().contains(settlementId); + } public void setBuyerName(String buyerName, UUID id) { this.buyerName = buyerName; @@ -196,4 +204,7 @@ public void setItem(ItemStack item) { public void setAuctionTime(long time) {this.auctionTime = time;} public void setPartiallySoldAmountLeft(int amount) {this.partiallySoldAmountLeft = amount;} public void setPrice(double amount) {this.price = amount;} + public void markPurchaseCommitted(UUID settlementId) { + getCommittedPurchaseSettlements().add(settlementId); + } } diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionDeliveryBackend.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionDeliveryBackend.java new file mode 100644 index 0000000..461bfd7 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionDeliveryBackend.java @@ -0,0 +1,6 @@ +package me.elaineqheart.auctionHouse.delivery; + +public enum AuctionDeliveryBackend { + SKYBLOCK_CORE, + INVENTORY_ONLY +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionDeliveryResult.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionDeliveryResult.java new file mode 100644 index 0000000..7ba5040 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionDeliveryResult.java @@ -0,0 +1,53 @@ +package me.elaineqheart.auctionHouse.delivery; + +import java.util.Objects; + +public record AuctionDeliveryResult( + String operationId, + AuctionDeliveryStatus status, + long requestedAmount, + long inventoryAmount, + long stashedAmount, + long undeliveredAmount, + boolean duplicate, + AuctionDeliveryBackend backend, + boolean apiUnavailable +) { + public AuctionDeliveryResult { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(backend, "backend"); + if (requestedAmount < 0 || inventoryAmount < 0 || stashedAmount < 0 + || undeliveredAmount < 0) { + throw new IllegalArgumentException("delivery amounts must not be negative"); + } + if (inventoryAmount + stashedAmount + undeliveredAmount != requestedAmount) { + throw new IllegalArgumentException("delivery amounts must add up to requestedAmount"); + } + } + + public boolean completed() { + return status == AuctionDeliveryStatus.COMPLETED; + } + + public static AuctionDeliveryResult inventoryCompleted(String operationId, long amount, + boolean apiUnavailable) { + return new AuctionDeliveryResult(operationId, AuctionDeliveryStatus.COMPLETED, + amount, amount, 0, 0, false, + AuctionDeliveryBackend.INVENTORY_ONLY, apiUnavailable); + } + + public static AuctionDeliveryResult inventoryRejected(String operationId, long amount, + boolean apiUnavailable) { + return new AuctionDeliveryResult(operationId, AuctionDeliveryStatus.REJECTED, + amount, 0, 0, amount, false, + AuctionDeliveryBackend.INVENTORY_ONLY, apiUnavailable); + } + + public static AuctionDeliveryResult inventoryFailed(String operationId, long amount, + boolean apiUnavailable) { + return new AuctionDeliveryResult(operationId, AuctionDeliveryStatus.FAILED, + amount, 0, 0, amount, false, + AuctionDeliveryBackend.INVENTORY_ONLY, apiUnavailable); + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionDeliveryStatus.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionDeliveryStatus.java new file mode 100644 index 0000000..6fa4781 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionDeliveryStatus.java @@ -0,0 +1,9 @@ +package me.elaineqheart.auctionHouse.delivery; + +public enum AuctionDeliveryStatus { + COMPLETED, + PARTIAL, + REJECTED, + CONFLICT, + FAILED +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionItemDeliveryService.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionItemDeliveryService.java new file mode 100644 index 0000000..d04a01d --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionItemDeliveryService.java @@ -0,0 +1,16 @@ +package me.elaineqheart.auctionHouse.delivery; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.util.Map; +import java.util.concurrent.CompletionStage; + +public interface AuctionItemDeliveryService { + CompletionStage deliver(Player recipient, + String operationId, + ItemStack item, + Map metadata); + + AuctionDeliveryBackend backend(); +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionProcessingLocks.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionProcessingLocks.java new file mode 100644 index 0000000..885c6f4 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/AuctionProcessingLocks.java @@ -0,0 +1,25 @@ +package me.elaineqheart.auctionHouse.delivery; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class AuctionProcessingLocks { + private final Set lockedNotes = ConcurrentHashMap.newKeySet(); + + public boolean tryLock(UUID noteId) { + return lockedNotes.add(noteId); + } + + public void restoreLock(UUID noteId) { + lockedNotes.add(noteId); + } + + public void unlock(UUID noteId) { + lockedNotes.remove(noteId); + } + + public boolean isLocked(UUID noteId) { + return lockedNotes.contains(noteId); + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/BukkitMainThreadExecutor.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/BukkitMainThreadExecutor.java new file mode 100644 index 0000000..fd22297 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/BukkitMainThreadExecutor.java @@ -0,0 +1,29 @@ +package me.elaineqheart.auctionHouse.delivery; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; + +import java.util.Objects; + +public final class BukkitMainThreadExecutor implements MainThreadExecutor { + private final Plugin plugin; + + public BukkitMainThreadExecutor(Plugin plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + } + + @Override + public boolean isMainThread() { + return Bukkit.isPrimaryThread(); + } + + @Override + public void execute(Runnable action) { + Objects.requireNonNull(action, "action"); + if (isMainThread()) { + action.run(); + return; + } + Bukkit.getScheduler().runTask(plugin, action); + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/DeliveryFeedback.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/DeliveryFeedback.java new file mode 100644 index 0000000..90c45c4 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/DeliveryFeedback.java @@ -0,0 +1,34 @@ +package me.elaineqheart.auctionHouse.delivery; + +import me.elaineqheart.auctionHouse.data.persistentStorage.local.configs.M; +import org.bukkit.entity.Player; + +public final class DeliveryFeedback { + private DeliveryFeedback() { + } + + public static void sendDestination(Player player, AuctionDeliveryResult result) { + if (result.stashedAmount() > 0) { + player.sendMessage(M.getFormatted("chat.item-delivery.stash")); + } else { + player.sendMessage(M.getFormatted("chat.item-delivery.inventory")); + } + } + + public static void sendFailure(Player player, AuctionDeliveryResult result) { + if (result != null && result.apiUnavailable()) { + player.sendMessage(M.getFormatted( + "chat.item-delivery.skyblock-core-unavailable")); + } + player.sendMessage(M.getFormatted("chat.item-delivery.preserved")); + } + + public static void sendProcessing(Player player) { + player.sendMessage(M.getFormatted("chat.item-delivery.processing")); + } + + public static void sendReconciliationRequired(Player player) { + player.sendMessage(M.getFormatted( + "chat.item-delivery.reconciliation-required")); + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/DeliveryOperationIds.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/DeliveryOperationIds.java new file mode 100644 index 0000000..e80fb05 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/DeliveryOperationIds.java @@ -0,0 +1,36 @@ +package me.elaineqheart.auctionHouse.delivery; + +import java.util.UUID; + +public final class DeliveryOperationIds { + private DeliveryOperationIds() { + } + + public static String cancel(UUID noteId, UUID ownerId) { + return note("cancel", noteId, ownerId); + } + + public static String expired(UUID noteId, UUID ownerId) { + return note("expired", noteId, ownerId); + } + + public static String adminExpired(UUID noteId, UUID ownerId) { + return note("admin-expired", noteId, ownerId); + } + + public static String bidWin(UUID noteId, UUID winnerId) { + return note("bid-win", noteId, winnerId); + } + + public static String adminReturn(UUID noteId, UUID adminId) { + return note("admin-return", noteId, adminId); + } + + public static String purchase(UUID settlementId) { + return "auctionhouse.purchase." + settlementId; + } + + private static String note(String action, UUID noteId, UUID recipientId) { + return "auctionhouse." + action + "." + noteId + "." + recipientId; + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/InventoryOnlyItemDeliveryService.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/InventoryOnlyItemDeliveryService.java new file mode 100644 index 0000000..4146ece --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/InventoryOnlyItemDeliveryService.java @@ -0,0 +1,92 @@ +package me.elaineqheart.auctionHouse.delivery; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; + +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +public final class InventoryOnlyItemDeliveryService implements AuctionItemDeliveryService { + private final MainThreadExecutor mainThread; + private final boolean apiUnavailable; + + public InventoryOnlyItemDeliveryService(MainThreadExecutor mainThread, + boolean apiUnavailable) { + this.mainThread = Objects.requireNonNull(mainThread, "mainThread"); + this.apiUnavailable = apiUnavailable; + } + + @Override + public CompletionStage deliver(Player recipient, + String operationId, + ItemStack item, + Map metadata) { + Objects.requireNonNull(recipient, "recipient"); + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(item, "item"); + CompletableFuture result = new CompletableFuture<>(); + mainThread.execute(() -> deliverOnMainThread(recipient, operationId, item, result)); + return result; + } + + private void deliverOnMainThread(Player recipient, String operationId, ItemStack source, + CompletableFuture target) { + mainThread.requireMainThread("deliver an AuctionHouse item"); + int amount = source.getAmount(); + PlayerInventory inventory = recipient.getInventory(); + if (!canFitCompletely(inventory, source)) { + target.complete(AuctionDeliveryResult.inventoryRejected( + operationId, amount, apiUnavailable)); + return; + } + + ItemStack[] before = cloneContents(inventory.getStorageContents()); + try { + Map leftovers = inventory.addItem(source.clone()); + if (leftovers.isEmpty()) { + target.complete(AuctionDeliveryResult.inventoryCompleted( + operationId, amount, apiUnavailable)); + return; + } + inventory.setStorageContents(before); + target.complete(AuctionDeliveryResult.inventoryRejected( + operationId, amount, apiUnavailable)); + } catch (Throwable failure) { + inventory.setStorageContents(before); + target.complete(AuctionDeliveryResult.inventoryFailed( + operationId, amount, apiUnavailable)); + } + } + + static boolean canFitCompletely(PlayerInventory inventory, ItemStack item) { + int remaining = item.getAmount(); + int slotLimit = Math.min(item.getMaxStackSize(), inventory.getMaxStackSize()); + for (ItemStack current : inventory.getStorageContents()) { + if (current == null || current.getType() == Material.AIR) { + remaining -= slotLimit; + } else if (current.isSimilar(item)) { + remaining -= Math.max(0, slotLimit - current.getAmount()); + } + if (remaining <= 0) { + return true; + } + } + return false; + } + + private static ItemStack[] cloneContents(ItemStack[] contents) { + return Arrays.stream(contents) + .map(item -> item == null ? null : item.clone()) + .toArray(ItemStack[]::new); + } + + @Override + public AuctionDeliveryBackend backend() { + return AuctionDeliveryBackend.INVENTORY_ONLY; + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/ItemDeliveryBootstrap.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/ItemDeliveryBootstrap.java new file mode 100644 index 0000000..d94dc95 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/ItemDeliveryBootstrap.java @@ -0,0 +1,47 @@ +package me.elaineqheart.auctionHouse.delivery; + +import org.bukkit.plugin.Plugin; + +import java.lang.reflect.Constructor; +import java.util.Objects; + +public final class ItemDeliveryBootstrap { + private static final String HOOK_CLASS = + "me.elaineqheart.auctionHouse.integration.skyblockcore.SkyblockCoreHook"; + + private ItemDeliveryBootstrap() { + } + + public static AuctionItemDeliveryService create(Plugin plugin, + MainThreadExecutor mainThread) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(mainThread, "mainThread"); + boolean enabled = plugin.getConfig().getBoolean( + "integrations.skyblock-core.enabled", true); + if (!enabled) { + return new InventoryOnlyItemDeliveryService(mainThread, false); + } + + Plugin dependency = plugin.getServer().getPluginManager().getPlugin("Skyblock-Core"); + if (dependency == null || !dependency.isEnabled()) { + plugin.getLogger().warning( + "Skyblock-Core is unavailable; using atomic inventory-only delivery."); + return new InventoryOnlyItemDeliveryService(mainThread, true); + } + + String source = plugin.getConfig().getString( + "integrations.skyblock-core.source", "auctionhouse"); + try { + Class type = Class.forName(HOOK_CLASS, true, plugin.getClass().getClassLoader()); + Constructor constructor = type.getConstructor( + Plugin.class, String.class, MainThreadExecutor.class); + return (AuctionItemDeliveryService) constructor.newInstance( + plugin, source, mainThread); + } catch (ReflectiveOperationException | LinkageError | RuntimeException failure) { + plugin.getLogger().log(java.util.logging.Level.WARNING, + "Skyblock-Core ItemDeliveryApi is unavailable; using atomic inventory-only delivery.", + failure); + return new InventoryOnlyItemDeliveryService(mainThread, true); + } + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/MainThreadExecutor.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/MainThreadExecutor.java new file mode 100644 index 0000000..2e40711 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/MainThreadExecutor.java @@ -0,0 +1,13 @@ +package me.elaineqheart.auctionHouse.delivery; + +public interface MainThreadExecutor { + boolean isMainThread(); + + void execute(Runnable action); + + default void requireMainThread(String action) { + if (!isMainThread()) { + throw new IllegalStateException(action + " must run on the Bukkit primary thread"); + } + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedDeliveryKind.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedDeliveryKind.java new file mode 100644 index 0000000..26eadf5 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedDeliveryKind.java @@ -0,0 +1,25 @@ +package me.elaineqheart.auctionHouse.delivery; + +import java.util.UUID; + +public enum RetainedDeliveryKind { + CANCEL, + EXPIRED, + ADMIN_EXPIRED, + BID_WIN, + ADMIN_RETURN; + + public String operationId(UUID noteId, UUID recipientId) { + return switch (this) { + case CANCEL -> DeliveryOperationIds.cancel(noteId, recipientId); + case EXPIRED -> DeliveryOperationIds.expired(noteId, recipientId); + case ADMIN_EXPIRED -> DeliveryOperationIds.adminExpired(noteId, recipientId); + case BID_WIN -> DeliveryOperationIds.bidWin(noteId, recipientId); + case ADMIN_RETURN -> DeliveryOperationIds.adminReturn(noteId, recipientId); + }; + } + + public String metadataAction() { + return name().toLowerCase(java.util.Locale.ROOT).replace('_', '-'); + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedDeliveryResult.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedDeliveryResult.java new file mode 100644 index 0000000..ccd3a18 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedDeliveryResult.java @@ -0,0 +1,12 @@ +package me.elaineqheart.auctionHouse.delivery; + +public record RetainedDeliveryResult( + RetainedDeliveryStatus status, + AuctionDeliveryResult delivery, + Throwable failure +) { + public static RetainedDeliveryResult processing() { + return new RetainedDeliveryResult( + RetainedDeliveryStatus.PROCESSING, null, null); + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedDeliveryStatus.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedDeliveryStatus.java new file mode 100644 index 0000000..ab38b2b --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedDeliveryStatus.java @@ -0,0 +1,8 @@ +package me.elaineqheart.auctionHouse.delivery; + +public enum RetainedDeliveryStatus { + PROCESSING, + COMMITTED, + DELIVERY_FAILED, + COMMIT_FAILED +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedItemDeliveryCoordinator.java b/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedItemDeliveryCoordinator.java new file mode 100644 index 0000000..28c1c87 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/delivery/RetainedItemDeliveryCoordinator.java @@ -0,0 +1,102 @@ +package me.elaineqheart.auctionHouse.delivery; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Keeps the AuctionHouse escrow record intact until a delivery acknowledgement is + * COMPLETED. Stable operation IDs make a retry after a crash safe. + */ +public final class RetainedItemDeliveryCoordinator { + private final AuctionItemDeliveryService delivery; + private final AuctionProcessingLocks locks; + private final MainThreadExecutor mainThread; + + public RetainedItemDeliveryCoordinator(AuctionItemDeliveryService delivery, + AuctionProcessingLocks locks, + MainThreadExecutor mainThread) { + this.delivery = Objects.requireNonNull(delivery, "delivery"); + this.locks = Objects.requireNonNull(locks, "locks"); + this.mainThread = Objects.requireNonNull(mainThread, "mainThread"); + } + + public CompletionStage deliver( + Player recipient, + UUID noteId, + RetainedDeliveryKind kind, + ItemStack item, + Map additionalMetadata, + Runnable commitSource + ) { + mainThread.requireMainThread("start an AuctionHouse escrow delivery"); + Objects.requireNonNull(recipient, "recipient"); + Objects.requireNonNull(noteId, "noteId"); + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(item, "item"); + Objects.requireNonNull(additionalMetadata, "additionalMetadata"); + Objects.requireNonNull(commitSource, "commitSource"); + if (!locks.tryLock(noteId)) { + return CompletableFuture.completedFuture(RetainedDeliveryResult.processing()); + } + + String operationId = kind.operationId(noteId, recipient.getUniqueId()); + Map metadata = new LinkedHashMap<>(); + metadata.put("note_uuid", noteId.toString()); + metadata.put("recipient_uuid", recipient.getUniqueId().toString()); + metadata.put("action", kind.metadataAction()); + metadata.putAll(additionalMetadata); + + CompletableFuture target = new CompletableFuture<>(); + CompletionStage submitted; + try { + submitted = Objects.requireNonNull( + delivery.deliver(recipient, operationId, item, Map.copyOf(metadata)), + "item delivery returned no stage"); + } catch (Throwable failure) { + locks.unlock(noteId); + target.complete(new RetainedDeliveryResult( + RetainedDeliveryStatus.DELIVERY_FAILED, null, failure)); + return target; + } + + submitted.whenComplete((result, failure) -> mainThread.execute(() -> { + if (failure != null || result == null || !result.completed()) { + locks.unlock(noteId); + target.complete(new RetainedDeliveryResult( + RetainedDeliveryStatus.DELIVERY_FAILED, + result, + unwrap(failure))); + return; + } + try { + commitSource.run(); + target.complete(new RetainedDeliveryResult( + RetainedDeliveryStatus.COMMITTED, result, null)); + } catch (Throwable commitFailure) { + target.complete(new RetainedDeliveryResult( + RetainedDeliveryStatus.COMMIT_FAILED, result, commitFailure)); + } finally { + locks.unlock(noteId); + } + })); + return target; + } + + private static Throwable unwrap(Throwable failure) { + Throwable current = failure; + while (current != null + && (current instanceof java.util.concurrent.CompletionException + || current instanceof java.util.concurrent.ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/integration/skyblockcore/SkyblockCoreHook.java b/src/main/java/me/elaineqheart/auctionHouse/integration/skyblockcore/SkyblockCoreHook.java new file mode 100644 index 0000000..253873c --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/integration/skyblockcore/SkyblockCoreHook.java @@ -0,0 +1,124 @@ +package me.elaineqheart.auctionHouse.integration.skyblockcore; + +import dev.chengzhi.skyblockcore.api.delivery.CapacityPolicy; +import dev.chengzhi.skyblockcore.api.delivery.DeliveryAtomicity; +import dev.chengzhi.skyblockcore.api.delivery.DeliveryPolicy; +import dev.chengzhi.skyblockcore.api.delivery.DeliveryRequest; +import dev.chengzhi.skyblockcore.api.delivery.DeliveryResult; +import dev.chengzhi.skyblockcore.api.delivery.ItemDeliveryApi; +import me.elaineqheart.auctionHouse.delivery.AuctionDeliveryBackend; +import me.elaineqheart.auctionHouse.delivery.AuctionDeliveryResult; +import me.elaineqheart.auctionHouse.delivery.AuctionDeliveryStatus; +import me.elaineqheart.auctionHouse.delivery.AuctionItemDeliveryService; +import me.elaineqheart.auctionHouse.delivery.MainThreadExecutor; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** Loaded reflectively so AuctionHouse can start without Skyblock-Core classes. */ +public final class SkyblockCoreHook implements AuctionItemDeliveryService { + private final ItemDeliveryApi api; + private final String source; + private final MainThreadExecutor mainThread; + + public SkyblockCoreHook(Plugin plugin, String source, MainThreadExecutor mainThread) { + Objects.requireNonNull(plugin, "plugin"); + this.source = requireSource(source); + this.mainThread = Objects.requireNonNull(mainThread, "mainThread"); + ItemDeliveryApi api = Bukkit.getServicesManager().load(ItemDeliveryApi.class); + if (api == null) { + throw new IllegalStateException("Skyblock-Core did not register ItemDeliveryApi"); + } + this.api = api; + } + + @Override + public CompletionStage deliver(Player recipient, + String operationId, + ItemStack item, + Map metadata) { + Objects.requireNonNull(recipient, "recipient"); + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(item, "item"); + Objects.requireNonNull(metadata, "metadata"); + CompletableFuture target = new CompletableFuture<>(); + mainThread.execute(() -> invokeOnMainThread( + recipient, operationId, item, metadata, target)); + return target; + } + + private void invokeOnMainThread(Player recipient, + String operationId, + ItemStack item, + Map metadata, + CompletableFuture target) { + mainThread.requireMainThread("construct and submit a DeliveryRequest"); + try { + DeliveryRequest request = new DeliveryRequest( + recipient.getUniqueId(), + source, + operationId, + List.of(item), + DeliveryPolicy.INVENTORY_THEN_STASH, + DeliveryAtomicity.REQUIRE_ALL, + CapacityPolicy.REJECT_TO_SOURCE, + metadata + ); + CompletionStage stage = api.deliver(request); + if (stage == null) { + throw new IllegalStateException("ItemDeliveryApi returned no stage"); + } + stage.whenComplete((result, failure) -> { + if (failure != null) { + target.completeExceptionally(failure); + return; + } + if (result == null || !operationId.equals(result.operationKey())) { + target.completeExceptionally(new IllegalStateException( + "ItemDeliveryApi returned an invalid acknowledgement")); + return; + } + try { + target.complete(map(result)); + } catch (Throwable invalidResult) { + target.completeExceptionally(invalidResult); + } + }); + } catch (Throwable failure) { + target.completeExceptionally(failure); + } + } + + private AuctionDeliveryResult map(DeliveryResult result) { + return new AuctionDeliveryResult( + result.operationKey(), + AuctionDeliveryStatus.valueOf(result.status().name()), + result.requestedAmount(), + result.inventoryAmount(), + result.stashedAmount(), + result.undeliveredAmount(), + result.duplicate(), + AuctionDeliveryBackend.SKYBLOCK_CORE, + false + ); + } + + private static String requireSource(String source) { + if (source == null || source.isBlank()) { + throw new IllegalArgumentException("Skyblock-Core delivery source must not be blank"); + } + return source; + } + + @Override + public AuctionDeliveryBackend backend() { + return AuctionDeliveryBackend.SKYBLOCK_CORE; + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/listeners/PurchaseSettlementRecoveryListener.java b/src/main/java/me/elaineqheart/auctionHouse/listeners/PurchaseSettlementRecoveryListener.java new file mode 100644 index 0000000..12551b0 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/listeners/PurchaseSettlementRecoveryListener.java @@ -0,0 +1,41 @@ +package me.elaineqheart.auctionHouse.listeners; + +import me.elaineqheart.auctionHouse.AuctionHouse; +import me.elaineqheart.auctionHouse.delivery.DeliveryFeedback; +import me.elaineqheart.auctionHouse.purchase.PurchaseResultStatus; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +import java.util.Objects; + +public final class PurchaseSettlementRecoveryListener implements Listener { + private final AuctionHouse plugin; + + public PurchaseSettlementRecoveryListener(AuctionHouse plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + for (var resumed : plugin.getPurchaseSettlements().resumeFor(event.getPlayer())) { + resumed.whenComplete((result, failure) -> { + if (failure != null || result == null) { + plugin.getLogger().warning( + "A purchase settlement recovery callback failed for " + + event.getPlayer().getUniqueId()); + return; + } + if (result.status() == PurchaseResultStatus.COMMITTED + && result.delivery() != null) { + DeliveryFeedback.sendDestination(event.getPlayer(), result.delivery()); + } else if (result.status() == PurchaseResultStatus.DELIVERY_FAILED) { + DeliveryFeedback.sendFailure(event.getPlayer(), result.delivery()); + } else if (result.status() == PurchaseResultStatus.RECONCILIATION_REQUIRED) { + DeliveryFeedback.sendReconciliationRequired(event.getPlayer()); + } + }); + } + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/BukkitPurchaseAuctionRepository.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/BukkitPurchaseAuctionRepository.java new file mode 100644 index 0000000..ec35018 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/BukkitPurchaseAuctionRepository.java @@ -0,0 +1,45 @@ +package me.elaineqheart.auctionHouse.purchase; + +import me.elaineqheart.auctionHouse.data.persistentStorage.ItemNoteStorage; +import me.elaineqheart.auctionHouse.data.ram.AuctionHouseStorage; +import me.elaineqheart.auctionHouse.data.ram.ItemNote; +import org.bukkit.inventory.ItemStack; + +import java.util.Optional; +import java.util.UUID; + +public final class BukkitPurchaseAuctionRepository implements PurchaseAuctionRepository { + @Override + public Optional findPurchasable(UUID noteId, int quantity) { + ItemNote note = AuctionHouseStorage.getNote(noteId); + if (note == null || note.isBIDAuction() || note.isExpired() + || !note.isTheoreticallyOnAuction() || quantity <= 0 + || note.getCurrentAmount() < quantity) { + return Optional.empty(); + } + ItemStack completeStack = note.getItem(); + double amount = note.getPrice() / completeStack.getAmount() * quantity; + ItemStack purchased = completeStack.clone(); + purchased.setAmount(quantity); + return Optional.of(new PurchaseListing(noteId, note.getPlayerUUID(), + note.getPlayerName(), purchased, quantity, amount)); + } + + @Override + public PurchaseCommitStatus commit(PurchaseSettlement settlement) { + ItemNote note = AuctionHouseStorage.getNote(settlement.noteUUID()); + if (note == null) return PurchaseCommitStatus.SOURCE_MISSING; + return switch (ItemNoteStorage.commitPurchase( + note, + settlement.settlementUUID(), + settlement.buyerName(), + settlement.buyerUUID(), + settlement.quantity(), + settlement.amount())) { + case COMMITTED -> PurchaseCommitStatus.COMMITTED; + case ALREADY_COMMITTED -> PurchaseCommitStatus.ALREADY_COMMITTED; + case SOURCE_MISSING -> PurchaseCommitStatus.SOURCE_MISSING; + case CONFLICT -> PurchaseCommitStatus.CONFLICT; + }; + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/EconomyGateway.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/EconomyGateway.java new file mode 100644 index 0000000..baaae04 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/EconomyGateway.java @@ -0,0 +1,11 @@ +package me.elaineqheart.auctionHouse.purchase; + +import org.bukkit.OfflinePlayer; + +public interface EconomyGateway { + double balance(OfflinePlayer player); + + EconomyTransaction withdraw(OfflinePlayer player, double amount); + + EconomyTransaction deposit(OfflinePlayer player, double amount); +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/EconomyTransaction.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/EconomyTransaction.java new file mode 100644 index 0000000..afca7ef --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/EconomyTransaction.java @@ -0,0 +1,15 @@ +package me.elaineqheart.auctionHouse.purchase; + +public record EconomyTransaction(EconomyTransactionStatus status, String detail) { + public static EconomyTransaction success() { + return new EconomyTransaction(EconomyTransactionStatus.SUCCESS, null); + } + + public static EconomyTransaction declined(String detail) { + return new EconomyTransaction(EconomyTransactionStatus.DECLINED, detail); + } + + public static EconomyTransaction unknown(String detail) { + return new EconomyTransaction(EconomyTransactionStatus.UNKNOWN, detail); + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/EconomyTransactionStatus.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/EconomyTransactionStatus.java new file mode 100644 index 0000000..a78e2c5 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/EconomyTransactionStatus.java @@ -0,0 +1,7 @@ +package me.elaineqheart.auctionHouse.purchase; + +public enum EconomyTransactionStatus { + SUCCESS, + DECLINED, + UNKNOWN +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/JsonPurchaseSettlementStore.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/JsonPurchaseSettlementStore.java new file mode 100644 index 0000000..1be5c85 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/JsonPurchaseSettlementStore.java @@ -0,0 +1,82 @@ +package me.elaineqheart.auctionHouse.purchase; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** One fsynced, atomically replaced JSON file per purchase settlement. */ +public final class JsonPurchaseSettlementStore implements PurchaseSettlementStore { + private final Path directory; + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private final Map settlements = new LinkedHashMap<>(); + + public JsonPurchaseSettlementStore(Path directory) throws IOException { + this.directory = directory; + Files.createDirectories(directory); + load(); + } + + private void load() throws IOException { + try (var files = Files.list(directory)) { + for (Path file : files.filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted().toList()) { + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + PurchaseSettlement settlement = gson.fromJson(reader, PurchaseSettlement.class); + if (settlement == null) { + throw new IOException("Empty settlement file: " + file); + } + settlements.put(settlement.settlementUUID(), settlement); + } catch (RuntimeException failure) { + throw new IOException("Invalid settlement file: " + file, failure); + } + } + } + } + + @Override + public synchronized void save(PurchaseSettlement settlement) throws IOException { + Path target = directory.resolve(settlement.settlementUUID() + ".json"); + Path temporary = directory.resolve(settlement.settlementUUID() + ".json.tmp"); + try (FileOutputStream output = new FileOutputStream(temporary.toFile()); + OutputStreamWriter writer = new OutputStreamWriter(output, StandardCharsets.UTF_8)) { + gson.toJson(settlement, writer); + writer.flush(); + output.getFD().sync(); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + settlements.put(settlement.settlementUUID(), settlement); + } + + @Override + public synchronized Optional find(UUID settlementId) { + return Optional.ofNullable(settlements.get(settlementId)); + } + + @Override + public synchronized Collection all() { + ArrayList copy = new ArrayList<>(settlements.values()); + copy.sort(Comparator.comparingLong(PurchaseSettlement::createdAt)); + return java.util.List.copyOf(copy); + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseAuctionRepository.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseAuctionRepository.java new file mode 100644 index 0000000..6f38301 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseAuctionRepository.java @@ -0,0 +1,10 @@ +package me.elaineqheart.auctionHouse.purchase; + +import java.util.Optional; +import java.util.UUID; + +public interface PurchaseAuctionRepository { + Optional findPurchasable(UUID noteId, int quantity); + + PurchaseCommitStatus commit(PurchaseSettlement settlement); +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseCommitStatus.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseCommitStatus.java new file mode 100644 index 0000000..994f3ab --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseCommitStatus.java @@ -0,0 +1,12 @@ +package me.elaineqheart.auctionHouse.purchase; + +public enum PurchaseCommitStatus { + COMMITTED, + ALREADY_COMMITTED, + SOURCE_MISSING, + CONFLICT; + + public boolean committed() { + return this == COMMITTED || this == ALREADY_COMMITTED; + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseListing.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseListing.java new file mode 100644 index 0000000..872d6f4 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseListing.java @@ -0,0 +1,15 @@ +package me.elaineqheart.auctionHouse.purchase; + +import org.bukkit.inventory.ItemStack; + +import java.util.UUID; + +public record PurchaseListing( + UUID noteId, + UUID sellerId, + String sellerName, + ItemStack item, + int quantity, + double amount +) { +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseResult.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseResult.java new file mode 100644 index 0000000..9387a4d --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseResult.java @@ -0,0 +1,14 @@ +package me.elaineqheart.auctionHouse.purchase; + +import me.elaineqheart.auctionHouse.delivery.AuctionDeliveryResult; + +public record PurchaseResult( + PurchaseResultStatus status, + PurchaseSettlement settlement, + AuctionDeliveryResult delivery, + Throwable failure +) { + public boolean committed() { + return status == PurchaseResultStatus.COMMITTED; + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseResultStatus.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseResultStatus.java new file mode 100644 index 0000000..0b133ea --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseResultStatus.java @@ -0,0 +1,12 @@ +package me.elaineqheart.auctionHouse.purchase; + +public enum PurchaseResultStatus { + COMMITTED, + PROCESSING, + NOT_AVAILABLE, + INSUFFICIENT_FUNDS, + PAYMENT_DECLINED, + DELIVERY_FAILED, + RECONCILIATION_REQUIRED, + FAILED +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlement.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlement.java new file mode 100644 index 0000000..6064b03 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlement.java @@ -0,0 +1,51 @@ +package me.elaineqheart.auctionHouse.purchase; + +import java.util.Objects; +import java.util.UUID; + +public record PurchaseSettlement( + UUID settlementUUID, + UUID noteUUID, + UUID buyerUUID, + String buyerName, + UUID sellerUUID, + String sellerName, + String itemData, + int quantity, + double amount, + String operationId, + PurchaseSettlementState state, + long createdAt, + String detail +) { + public PurchaseSettlement { + Objects.requireNonNull(settlementUUID, "settlementUUID"); + Objects.requireNonNull(noteUUID, "noteUUID"); + Objects.requireNonNull(buyerUUID, "buyerUUID"); + Objects.requireNonNull(buyerName, "buyerName"); + Objects.requireNonNull(sellerUUID, "sellerUUID"); + Objects.requireNonNull(sellerName, "sellerName"); + if (itemData == null || itemData.isBlank()) { + throw new IllegalArgumentException("itemData must not be blank"); + } + if (quantity <= 0) { + throw new IllegalArgumentException("quantity must be positive"); + } + if (!Double.isFinite(amount) || amount < 0) { + throw new IllegalArgumentException("amount must be finite and non-negative"); + } + if (operationId == null || operationId.isBlank()) { + throw new IllegalArgumentException("operationId must not be blank"); + } + Objects.requireNonNull(state, "state"); + if (createdAt <= 0) { + throw new IllegalArgumentException("createdAt must be positive"); + } + } + + public PurchaseSettlement withState(PurchaseSettlementState newState, String detail) { + return new PurchaseSettlement(settlementUUID, noteUUID, buyerUUID, buyerName, + sellerUUID, sellerName, itemData, quantity, amount, operationId, + newState, createdAt, detail); + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementService.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementService.java new file mode 100644 index 0000000..891520b --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementService.java @@ -0,0 +1,423 @@ +package me.elaineqheart.auctionHouse.purchase; + +import me.elaineqheart.auctionHouse.data.persistentStorage.ItemStackConverter; +import me.elaineqheart.auctionHouse.delivery.AuctionDeliveryResult; +import me.elaineqheart.auctionHouse.delivery.AuctionItemDeliveryService; +import me.elaineqheart.auctionHouse.delivery.AuctionProcessingLocks; +import me.elaineqheart.auctionHouse.delivery.DeliveryOperationIds; +import me.elaineqheart.auctionHouse.delivery.MainThreadExecutor; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.io.IOException; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** Durable BIN purchase saga. All entry points and note mutations run on the main thread. */ +public final class PurchaseSettlementService { + private final PurchaseSettlementStore store; + private final PurchaseAuctionRepository auctions; + private final EconomyGateway economy; + private final AuctionItemDeliveryService delivery; + private final AuctionProcessingLocks locks; + private final MainThreadExecutor mainThread; + private final Supplier settlementIds; + private final Clock clock; + private final Logger logger; + private final java.util.Set inFlight = java.util.concurrent.ConcurrentHashMap.newKeySet(); + + public PurchaseSettlementService(PurchaseSettlementStore store, + PurchaseAuctionRepository auctions, + EconomyGateway economy, + AuctionItemDeliveryService delivery, + AuctionProcessingLocks locks, + MainThreadExecutor mainThread, + Supplier settlementIds, + Clock clock, + Logger logger) { + this.store = Objects.requireNonNull(store, "store"); + this.auctions = Objects.requireNonNull(auctions, "auctions"); + this.economy = Objects.requireNonNull(economy, "economy"); + this.delivery = Objects.requireNonNull(delivery, "delivery"); + this.locks = Objects.requireNonNull(locks, "locks"); + this.mainThread = Objects.requireNonNull(mainThread, "mainThread"); + this.settlementIds = Objects.requireNonNull(settlementIds, "settlementIds"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + public CompletionStage purchase(Player buyer, UUID noteId, int quantity) { + mainThread.requireMainThread("start a BIN purchase settlement"); + Objects.requireNonNull(buyer, "buyer"); + Objects.requireNonNull(noteId, "noteId"); + if (!locks.tryLock(noteId)) { + return completed(PurchaseResultStatus.PROCESSING, null, null, null); + } + + PurchaseListing listing = auctions.findPurchasable(noteId, quantity).orElse(null); + if (listing == null || listing.sellerId().equals(buyer.getUniqueId())) { + locks.unlock(noteId); + return completed(PurchaseResultStatus.NOT_AVAILABLE, null, null, null); + } + try { + if (economy.balance(buyer) < listing.amount()) { + locks.unlock(noteId); + return completed(PurchaseResultStatus.INSUFFICIENT_FUNDS, + null, null, null); + } + } catch (Throwable failure) { + locks.unlock(noteId); + return completed(PurchaseResultStatus.FAILED, null, null, failure); + } + + UUID settlementId = settlementIds.get(); + PurchaseSettlement settlement; + try { + settlement = new PurchaseSettlement( + settlementId, + noteId, + buyer.getUniqueId(), + buyer.getDisplayName(), + listing.sellerId(), + listing.sellerName(), + ItemStackConverter.encode(listing.item().clone()), + listing.quantity(), + listing.amount(), + DeliveryOperationIds.purchase(settlementId), + PurchaseSettlementState.PREPARED, + clock.millis(), + null + ); + store.save(settlement); + settlement = transition(settlement, PurchaseSettlementState.ITEM_RESERVED, null); + settlement = transition(settlement, + PurchaseSettlementState.FUNDS_WITHDRAWAL_STARTED, null); + } catch (Throwable failure) { + locks.unlock(noteId); + return completed(PurchaseResultStatus.FAILED, null, null, failure); + } + + EconomyTransaction withdrawal; + try { + withdrawal = economy.withdraw(buyer, settlement.amount()); + } catch (Throwable failure) { + PurchaseSettlement uncertain = reconciliation(settlement, + "Vault withdrawal threw an exception: " + failure); + return completed(PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, null, failure); + } + if (withdrawal == null || withdrawal.status() == EconomyTransactionStatus.UNKNOWN) { + PurchaseSettlement uncertain = reconciliation(settlement, + withdrawal == null ? "Vault returned no withdrawal result" : withdrawal.detail()); + return completed(PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, null, null); + } + if (withdrawal.status() == EconomyTransactionStatus.DECLINED) { + try { + PurchaseSettlement aborted = transition(settlement, + PurchaseSettlementState.ABORTED, withdrawal.detail()); + locks.unlock(noteId); + return completed(PurchaseResultStatus.PAYMENT_DECLINED, + aborted, null, null); + } catch (IOException failure) { + PurchaseSettlement uncertain = reconciliation(settlement, + "Could not persist a declined withdrawal: " + failure); + return completed(PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, null, failure); + } + } + + try { + settlement = transition(settlement, PurchaseSettlementState.FUNDS_WITHDRAWN, null); + } catch (IOException failure) { + PurchaseSettlement uncertain = reconciliation(settlement, + "Funds were withdrawn but the state could not be persisted: " + failure); + return completed(PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, null, failure); + } + + CompletableFuture target = new CompletableFuture<>(); + submitDelivery(buyer, settlement, target); + return target; + } + + /** Restores durable locks and resumes safe states after notes have been loaded. */ + public List> recover(Collection onlinePlayers) { + mainThread.requireMainThread("recover purchase settlements"); + Map online = new LinkedHashMap<>(); + for (Player player : onlinePlayers) online.put(player.getUniqueId(), player); + List> resumed = new ArrayList<>(); + for (PurchaseSettlement settlement : store.all()) { + switch (settlement.state()) { + case COMMITTED, ABORTED -> { + } + case PREPARED, ITEM_RESERVED -> abortUnchargedSettlement(settlement); + case FUNDS_WITHDRAWAL_STARTED, REFUND_STARTED -> { + locks.restoreLock(settlement.noteUUID()); + reconciliation(settlement, + "Recovery found an interrupted Vault operation"); + } + case RECONCILIATION_REQUIRED -> locks.restoreLock(settlement.noteUUID()); + case DELIVERY_COMPLETED -> { + locks.restoreLock(settlement.noteUUID()); + resumed.add(commitRecovered(settlement)); + } + case FUNDS_WITHDRAWN -> { + locks.restoreLock(settlement.noteUUID()); + Player buyer = online.get(settlement.buyerUUID()); + if (buyer != null) resumed.add(resumeDelivery(buyer, settlement)); + } + } + } + return List.copyOf(resumed); + } + + public List> resumeFor(Player buyer) { + mainThread.requireMainThread("resume purchase settlements for a player"); + List> resumed = new ArrayList<>(); + for (PurchaseSettlement settlement : store.all()) { + if (settlement.state() == PurchaseSettlementState.FUNDS_WITHDRAWN + && settlement.buyerUUID().equals(buyer.getUniqueId())) { + locks.restoreLock(settlement.noteUUID()); + resumed.add(resumeDelivery(buyer, settlement)); + } + } + return List.copyOf(resumed); + } + + private CompletionStage resumeDelivery(Player buyer, + PurchaseSettlement settlement) { + CompletableFuture target = new CompletableFuture<>(); + submitDelivery(buyer, settlement, target); + return target; + } + + private void submitDelivery(Player buyer, + PurchaseSettlement settlement, + CompletableFuture target) { + if (!inFlight.add(settlement.settlementUUID())) { + target.complete(new PurchaseResult(PurchaseResultStatus.PROCESSING, + settlement, null, null)); + return; + } + ItemStack item; + try { + item = ItemStackConverter.decode(settlement.itemData()); + } catch (Throwable failure) { + inFlight.remove(settlement.settlementUUID()); + PurchaseSettlement uncertain = reconciliation(settlement, + "The stored item snapshot could not be decoded: " + failure); + target.complete(new PurchaseResult( + PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, null, failure)); + return; + } + Map metadata = Map.of( + "settlement_uuid", settlement.settlementUUID().toString(), + "note_uuid", settlement.noteUUID().toString(), + "seller_uuid", settlement.sellerUUID().toString(), + "action", "purchase" + ); + CompletionStage submitted; + try { + submitted = Objects.requireNonNull( + delivery.deliver(buyer, settlement.operationId(), item, metadata), + "item delivery returned no stage"); + } catch (Throwable failure) { + inFlight.remove(settlement.settlementUUID()); + refundAfterDeliveryFailure(buyer, settlement, null, failure, target); + return; + } + submitted.whenComplete((result, failure) -> mainThread.execute(() -> { + inFlight.remove(settlement.settlementUUID()); + if (failure != null || result == null || !result.completed()) { + refundAfterDeliveryFailure(buyer, settlement, result, + unwrap(failure), target); + return; + } + deliveryCompleted(settlement, result, target); + })); + } + + private void deliveryCompleted(PurchaseSettlement settlement, + AuctionDeliveryResult result, + CompletableFuture target) { + PurchaseSettlement delivered; + try { + delivered = transition(settlement, + PurchaseSettlementState.DELIVERY_COMPLETED, null); + } catch (IOException failure) { + PurchaseSettlement uncertain = reconciliation(settlement, + "Delivery completed but its state could not be persisted: " + failure); + target.complete(new PurchaseResult( + PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, result, failure)); + return; + } + commit(delivered, result, target); + } + + private CompletionStage commitRecovered(PurchaseSettlement settlement) { + CompletableFuture target = new CompletableFuture<>(); + commit(settlement, null, target); + return target; + } + + private void commit(PurchaseSettlement settlement, + AuctionDeliveryResult result, + CompletableFuture target) { + PurchaseCommitStatus commitStatus; + try { + commitStatus = auctions.commit(settlement); + } catch (Throwable failure) { + PurchaseSettlement uncertain = reconciliation(settlement, + "Auction state commit failed: " + failure); + target.complete(new PurchaseResult( + PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, result, failure)); + return; + } + if (!commitStatus.committed()) { + PurchaseSettlement uncertain = reconciliation(settlement, + "Auction source commit returned " + commitStatus); + target.complete(new PurchaseResult( + PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, result, null)); + return; + } + try { + PurchaseSettlement committed = transition(settlement, + PurchaseSettlementState.COMMITTED, null); + locks.unlock(settlement.noteUUID()); + target.complete(new PurchaseResult(PurchaseResultStatus.COMMITTED, + committed, result, null)); + } catch (IOException failure) { + PurchaseSettlement uncertain = reconciliation(settlement, + "Auction committed but settlement finalization failed: " + failure); + target.complete(new PurchaseResult( + PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, result, failure)); + } + } + + private void refundAfterDeliveryFailure(Player buyer, + PurchaseSettlement settlement, + AuctionDeliveryResult deliveryResult, + Throwable deliveryFailure, + CompletableFuture target) { + PurchaseSettlement refunding; + try { + refunding = transition(settlement, PurchaseSettlementState.REFUND_STARTED, + deliveryFailure == null ? null : deliveryFailure.toString()); + } catch (IOException failure) { + PurchaseSettlement uncertain = reconciliation(settlement, + "Could not persist refund intent: " + failure); + target.complete(new PurchaseResult( + PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, deliveryResult, failure)); + return; + } + + EconomyTransaction refund; + try { + refund = economy.deposit(buyer, settlement.amount()); + } catch (Throwable failure) { + PurchaseSettlement uncertain = reconciliation(refunding, + "Vault refund threw an exception: " + failure); + target.complete(new PurchaseResult( + PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, deliveryResult, failure)); + return; + } + if (refund == null || refund.status() != EconomyTransactionStatus.SUCCESS) { + String detail = refund == null ? "Vault returned no refund result" : refund.detail(); + PurchaseSettlement uncertain = reconciliation(refunding, detail); + target.complete(new PurchaseResult( + PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, deliveryResult, deliveryFailure)); + return; + } + try { + PurchaseSettlement aborted = transition(refunding, + PurchaseSettlementState.ABORTED, + deliveryFailure == null ? null : deliveryFailure.toString()); + locks.unlock(settlement.noteUUID()); + target.complete(new PurchaseResult(PurchaseResultStatus.DELIVERY_FAILED, + aborted, deliveryResult, deliveryFailure)); + } catch (IOException failure) { + PurchaseSettlement uncertain = reconciliation(refunding, + "Refund completed but ABORTED could not be persisted: " + failure); + target.complete(new PurchaseResult( + PurchaseResultStatus.RECONCILIATION_REQUIRED, + uncertain, deliveryResult, failure)); + } + } + + private void abortUnchargedSettlement(PurchaseSettlement settlement) { + try { + transition(settlement, PurchaseSettlementState.ABORTED, + "Recovered before Vault withdrawal began"); + locks.unlock(settlement.noteUUID()); + } catch (IOException failure) { + logger.log(Level.SEVERE, + "Could not abort uncharged purchase settlement " + + settlement.settlementUUID(), failure); + } + } + + private PurchaseSettlement reconciliation(PurchaseSettlement settlement, String detail) { + PurchaseSettlement uncertain = settlement.withState( + PurchaseSettlementState.RECONCILIATION_REQUIRED, detail); + locks.restoreLock(settlement.noteUUID()); + logger.warning("Purchase settlement " + settlement.settlementUUID() + + " requires administrator reconciliation: " + detail); + try { + store.save(uncertain); + return uncertain; + } catch (IOException failure) { + logger.log(Level.SEVERE, + "Could not persist RECONCILIATION_REQUIRED for settlement " + + settlement.settlementUUID(), failure); + return uncertain; + } + } + + private PurchaseSettlement transition(PurchaseSettlement settlement, + PurchaseSettlementState state, + String detail) throws IOException { + PurchaseSettlement changed = settlement.withState(state, detail); + store.save(changed); + return changed; + } + + private static CompletionStage completed( + PurchaseResultStatus status, + PurchaseSettlement settlement, + AuctionDeliveryResult delivery, + Throwable failure) { + return CompletableFuture.completedFuture( + new PurchaseResult(status, settlement, delivery, failure)); + } + + private static Throwable unwrap(Throwable failure) { + Throwable current = failure; + while (current != null + && (current instanceof java.util.concurrent.CompletionException + || current instanceof java.util.concurrent.ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementState.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementState.java new file mode 100644 index 0000000..646fec5 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementState.java @@ -0,0 +1,17 @@ +package me.elaineqheart.auctionHouse.purchase; + +public enum PurchaseSettlementState { + PREPARED, + ITEM_RESERVED, + FUNDS_WITHDRAWAL_STARTED, + FUNDS_WITHDRAWN, + REFUND_STARTED, + DELIVERY_COMPLETED, + COMMITTED, + RECONCILIATION_REQUIRED, + ABORTED; + + public boolean terminal() { + return this == COMMITTED || this == ABORTED; + } +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementStore.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementStore.java new file mode 100644 index 0000000..c0340c5 --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementStore.java @@ -0,0 +1,14 @@ +package me.elaineqheart.auctionHouse.purchase; + +import java.io.IOException; +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; + +public interface PurchaseSettlementStore { + void save(PurchaseSettlement settlement) throws IOException; + + Optional find(UUID settlementId); + + Collection all(); +} diff --git a/src/main/java/me/elaineqheart/auctionHouse/purchase/VaultEconomyGateway.java b/src/main/java/me/elaineqheart/auctionHouse/purchase/VaultEconomyGateway.java new file mode 100644 index 0000000..c2b643f --- /dev/null +++ b/src/main/java/me/elaineqheart/auctionHouse/purchase/VaultEconomyGateway.java @@ -0,0 +1,53 @@ +package me.elaineqheart.auctionHouse.purchase; + +import me.elaineqheart.auctionHouse.pluginDependencies.VaultHook; +import net.milkbowl.vault.economy.Economy; +import net.milkbowl.vault.economy.EconomyResponse; +import org.bukkit.OfflinePlayer; + +public final class VaultEconomyGateway implements EconomyGateway { + @Override + public double balance(OfflinePlayer player) { + Economy economy = requireEconomy(); + return economy.getBalance(player); + } + + @Override + public EconomyTransaction withdraw(OfflinePlayer player, double amount) { + try { + return map(requireEconomy().withdrawPlayer(player, amount)); + } catch (Throwable failure) { + return EconomyTransaction.unknown(failure.toString()); + } + } + + @Override + public EconomyTransaction deposit(OfflinePlayer player, double amount) { + try { + return map(requireEconomy().depositPlayer(player, amount)); + } catch (Throwable failure) { + return EconomyTransaction.unknown(failure.toString()); + } + } + + private static Economy requireEconomy() { + Economy economy = VaultHook.getEconomy(); + if (economy == null) { + throw new IllegalStateException("No Vault economy provider is registered"); + } + return economy; + } + + private static EconomyTransaction map(EconomyResponse response) { + if (response == null) { + return EconomyTransaction.unknown("Vault returned null"); + } + if (response.transactionSuccess()) { + return EconomyTransaction.success(); + } + if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) { + return EconomyTransaction.unknown(response.errorMessage); + } + return EconomyTransaction.declined(response.errorMessage); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d4458d9..184aa03 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -17,4 +17,9 @@ min-bid: 1 # the minimum bid price max-bid: -1 # limit the bid price; set to -1 to disable last-bid-extra-time: 60 # if the auction time is less than this when a bid is placed, it's set to this bid-increase-percent: 25 # how much each bid has to increase at least -use-adventure-text-minimessages: true # support for miniMessages like in messages.yml \ No newline at end of file +use-adventure-text-minimessages: true # support for miniMessages like in messages.yml + +integrations: + skyblock-core: + enabled: true + source: 'auctionhouse' diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index dc3d7f5..161bb71 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -26,6 +26,14 @@ # chat messages from the auction house chat: + item-delivery: + inventory: "§a物品已放入背包" + stash: "§e背包已满,溢出物已进入 Stash" + preserved: "§c背包与 Stash 均无法接收,拍卖物品仍被安全保留" + processing: "§e正在处理,请勿重复点击" + skyblock-core-unavailable: "§cSkyblock-Core API 不可用" + reconciliation-required: "§c交易状态不确定,需要管理员处理" + payment-failed: "§c扣款失败,交易已取消" already-sold: "§cERROR! This item has already been sold!" already-sold2: "§cThis item has already been sold!" already-sold3: "§cAnother player already placed a bid!" @@ -558,4 +566,4 @@ placeholders: format-time-characters: "dhms" player: "%player_name%" seller: "%player_name%" - buyer: "%player_name%" \ No newline at end of file + buyer: "%player_name%" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 78e8d31..3ba5d9d 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -6,8 +6,11 @@ folia-supported: true authors: [ ElaineQheart ] website: https://www.youtube.com/@elaineqheart depend: [ Vault ] -softdepend: [ PlaceholderAPI, Locale-API ] +softdepend: + - PlaceholderAPI + - Locale-API + - Skyblock-Core permissions: auctionhouse.ah: description: allows the player to use /ah - default: true \ No newline at end of file + default: true diff --git a/src/test/java/me/elaineqheart/auctionHouse/data/persistentStorage/ItemStackConverterTest.java b/src/test/java/me/elaineqheart/auctionHouse/data/persistentStorage/ItemStackConverterTest.java new file mode 100644 index 0000000..cf1b781 --- /dev/null +++ b/src/test/java/me/elaineqheart/auctionHouse/data/persistentStorage/ItemStackConverterTest.java @@ -0,0 +1,72 @@ +package me.elaineqheart.auctionHouse.data.persistentStorage; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.block.ShulkerBox; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BlockStateMeta; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +class ItemStackConverterTest { + @BeforeEach + void setUp() { + MockBukkit.mock(); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void richItemRoundTripPreservesPdcComponentsLoreEnchantsAndContainerContents() { + NamespacedKey outerKey = new NamespacedKey("auctionhouse", "outer-pdc"); + NamespacedKey innerKey = new NamespacedKey("auctionhouse", "inner-pdc"); + + ItemStack sword = new ItemStack(Material.DIAMOND_SWORD); + ItemMeta swordMeta = sword.getItemMeta(); + swordMeta.setDisplayName("§6Saga Blade"); + swordMeta.setItemName("auctionhouse:saga_blade"); + swordMeta.setLore(List.of("§7Persistent lore", "§dComponent-safe")); + swordMeta.setCustomModelData(4242); + swordMeta.addEnchant(Enchantment.SHARPNESS, 5, true); + swordMeta.getPersistentDataContainer().set( + innerKey, PersistentDataType.STRING, "inner-value"); + sword.setItemMeta(swordMeta); + + ItemStack original = new ItemStack(Material.PURPLE_SHULKER_BOX); + BlockStateMeta shulkerMeta = (BlockStateMeta) original.getItemMeta(); + shulkerMeta.setDisplayName("§5Auction Container"); + shulkerMeta.setLore(List.of("§7Contains a custom item")); + shulkerMeta.getPersistentDataContainer().set( + outerKey, PersistentDataType.INTEGER, 73); + ShulkerBox box = (ShulkerBox) shulkerMeta.getBlockState(); + box.getInventory().setItem(4, sword); + shulkerMeta.setBlockState(box); + original.setItemMeta(shulkerMeta); + + ItemStack decoded = ItemStackConverter.decode(ItemStackConverter.encode(original)); + + assertNotSame(original, decoded); + assertEquals(original, decoded); + BlockStateMeta decodedMeta = (BlockStateMeta) decoded.getItemMeta(); + assertEquals(73, decodedMeta.getPersistentDataContainer().get( + outerKey, PersistentDataType.INTEGER)); + ItemStack decodedSword = ((ShulkerBox) decodedMeta.getBlockState()) + .getInventory().getItem(4); + assertEquals(sword, decodedSword); + assertEquals("inner-value", decodedSword.getItemMeta() + .getPersistentDataContainer().get(innerKey, PersistentDataType.STRING)); + } +} diff --git a/src/test/java/me/elaineqheart/auctionHouse/delivery/DeliveryOperationIdsTest.java b/src/test/java/me/elaineqheart/auctionHouse/delivery/DeliveryOperationIdsTest.java new file mode 100644 index 0000000..2119512 --- /dev/null +++ b/src/test/java/me/elaineqheart/auctionHouse/delivery/DeliveryOperationIdsTest.java @@ -0,0 +1,38 @@ +package me.elaineqheart.auctionHouse.delivery; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class DeliveryOperationIdsTest { + private static final UUID NOTE = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + @Test + void noteOperationIdsMatchTheStableContract() { + assertEquals("auctionhouse.cancel." + NOTE + "." + PLAYER, + DeliveryOperationIds.cancel(NOTE, PLAYER)); + assertEquals("auctionhouse.expired." + NOTE + "." + PLAYER, + DeliveryOperationIds.expired(NOTE, PLAYER)); + assertEquals("auctionhouse.admin-expired." + NOTE + "." + PLAYER, + DeliveryOperationIds.adminExpired(NOTE, PLAYER)); + assertEquals("auctionhouse.bid-win." + NOTE + "." + PLAYER, + DeliveryOperationIds.bidWin(NOTE, PLAYER)); + assertEquals("auctionhouse.admin-return." + NOTE + "." + PLAYER, + DeliveryOperationIds.adminReturn(NOTE, PLAYER)); + } + + @Test + void purchaseOperationDependsOnlyOnItsUniqueSettlement() { + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + + assertNotEquals(DeliveryOperationIds.purchase(first), + DeliveryOperationIds.purchase(second)); + assertEquals("auctionhouse.purchase." + first, + DeliveryOperationIds.purchase(first)); + } +} diff --git a/src/test/java/me/elaineqheart/auctionHouse/delivery/InventoryOnlyItemDeliveryServiceTest.java b/src/test/java/me/elaineqheart/auctionHouse/delivery/InventoryOnlyItemDeliveryServiceTest.java new file mode 100644 index 0000000..9060dfc --- /dev/null +++ b/src/test/java/me/elaineqheart/auctionHouse/delivery/InventoryOnlyItemDeliveryServiceTest.java @@ -0,0 +1,93 @@ +package me.elaineqheart.auctionHouse.delivery; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InventoryOnlyItemDeliveryServiceTest { + private ServerMock server; + private PlayerMock player; + private InventoryOnlyItemDeliveryService service; + + @BeforeEach + void setUp() { + server = MockBukkit.mock(); + player = server.addPlayer(); + service = new InventoryOnlyItemDeliveryService(new ImmediateMainThread(), false); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void deliversIntoInventoryWhenSpaceExists() { + AuctionDeliveryResult result = service.deliver(player, "test.inventory", + new ItemStack(Material.DIAMOND, 3), Map.of()).toCompletableFuture().join(); + + assertTrue(result.completed()); + assertEquals(3, result.inventoryAmount()); + assertEquals(3, count(Material.DIAMOND)); + } + + @Test + void fullInventoryStillAcceptsAnExistingPartialStack() { + fillStorage(Material.DIRT); + player.getInventory().setItem(0, new ItemStack(Material.DIAMOND, 63)); + + AuctionDeliveryResult result = service.deliver(player, "test.partial-stack", + new ItemStack(Material.DIAMOND), Map.of()).toCompletableFuture().join(); + + assertTrue(result.completed()); + assertEquals(64, player.getInventory().getItem(0).getAmount()); + } + + @Test + void rejectsAtomicallyWhenInventoryCannotFitTheItem() { + fillStorage(Material.DIRT); + + AuctionDeliveryResult result = service.deliver(player, "test.full", + new ItemStack(Material.DIAMOND), Map.of()).toCompletableFuture().join(); + + assertEquals(AuctionDeliveryStatus.REJECTED, result.status()); + assertEquals(0, count(Material.DIAMOND)); + assertEquals(64, player.getInventory().getItem(0).getAmount()); + } + + private void fillStorage(Material material) { + for (int slot = 0; slot < player.getInventory().getStorageContents().length; slot++) { + player.getInventory().setItem(slot, new ItemStack(material, 64)); + } + } + + private int count(Material material) { + int amount = 0; + for (ItemStack item : player.getInventory().getStorageContents()) { + if (item != null && item.getType() == material) amount += item.getAmount(); + } + return amount; + } + + private static final class ImmediateMainThread implements MainThreadExecutor { + @Override + public boolean isMainThread() { + return true; + } + + @Override + public void execute(Runnable action) { + action.run(); + } + } +} diff --git a/src/test/java/me/elaineqheart/auctionHouse/delivery/ItemDeliveryBootstrapTest.java b/src/test/java/me/elaineqheart/auctionHouse/delivery/ItemDeliveryBootstrapTest.java new file mode 100644 index 0000000..05d3552 --- /dev/null +++ b/src/test/java/me/elaineqheart/auctionHouse/delivery/ItemDeliveryBootstrapTest.java @@ -0,0 +1,45 @@ +package me.elaineqheart.auctionHouse.delivery; + +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ItemDeliveryBootstrapTest { + @BeforeEach + void setUp() { + MockBukkit.mock(); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void missingSkyblockCoreFallsBackWithoutPreventingStartup() { + Plugin plugin = MockBukkit.createMockPlugin(); + plugin.getConfig().set("integrations.skyblock-core.enabled", true); + + AuctionItemDeliveryService service = assertDoesNotThrow(() -> + ItemDeliveryBootstrap.create(plugin, new ImmediateMainThread())); + + assertEquals(AuctionDeliveryBackend.INVENTORY_ONLY, service.backend()); + } + + private static final class ImmediateMainThread implements MainThreadExecutor { + @Override + public boolean isMainThread() { + return true; + } + + @Override + public void execute(Runnable action) { + action.run(); + } + } +} diff --git a/src/test/java/me/elaineqheart/auctionHouse/delivery/RetainedItemDeliveryCoordinatorTest.java b/src/test/java/me/elaineqheart/auctionHouse/delivery/RetainedItemDeliveryCoordinatorTest.java new file mode 100644 index 0000000..34e7d2b --- /dev/null +++ b/src/test/java/me/elaineqheart/auctionHouse/delivery/RetainedItemDeliveryCoordinatorTest.java @@ -0,0 +1,163 @@ +package me.elaineqheart.auctionHouse.delivery; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RetainedItemDeliveryCoordinatorTest { + private PlayerMock player; + private IdempotentDelivery delivery; + private RetainedItemDeliveryCoordinator coordinator; + + @BeforeEach + void setUp() { + MockBukkit.mock(); + player = MockBukkit.getMock().addPlayer(); + delivery = new IdempotentDelivery(); + coordinator = new RetainedItemDeliveryCoordinator(delivery, + new AuctionProcessingLocks(), new ImmediateMainThread()); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void rejectedDeliveryNeverCommitsTheEscrowSource() { + delivery.reject = true; + AtomicInteger commits = new AtomicInteger(); + + RetainedDeliveryResult result = coordinator.deliver(player, UUID.randomUUID(), + RetainedDeliveryKind.EXPIRED, new ItemStack(Material.DIAMOND), + Map.of(), commits::incrementAndGet).toCompletableFuture().join(); + + assertEquals(RetainedDeliveryStatus.DELIVERY_FAILED, result.status()); + assertEquals(0, commits.get()); + } + + @ParameterizedTest + @EnumSource(value = RetainedDeliveryKind.class, + names = {"CANCEL", "EXPIRED", "BID_WIN"}) + void completedEscrowPathCanOnlyBeClaimedOnce(RetainedDeliveryKind kind) { + UUID noteId = UUID.randomUUID(); + AtomicBoolean sourceExists = new AtomicBoolean(true); + AtomicInteger commits = new AtomicInteger(); + + attemptIfPresent(noteId, kind, sourceExists, commits); + attemptIfPresent(noteId, kind, sourceExists, commits); + + assertFalse(sourceExists.get()); + assertEquals(1, commits.get()); + assertEquals(1, delivery.calls); + assertEquals(1, delivery.uniqueGrants); + } + + @Test + void crashAfterDeliveryBeforeSourceDeletionRetriesWithoutAnotherGrant() { + UUID noteId = UUID.randomUUID(); + AtomicBoolean sourceExists = new AtomicBoolean(true); + AtomicInteger attempts = new AtomicInteger(); + + RetainedDeliveryResult first = coordinator.deliver(player, noteId, + RetainedDeliveryKind.CANCEL, new ItemStack(Material.DIAMOND), Map.of(), () -> { + attempts.incrementAndGet(); + throw new IllegalStateException("simulated crash window"); + }).toCompletableFuture().join(); + RetainedDeliveryResult recovered = coordinator.deliver(player, noteId, + RetainedDeliveryKind.CANCEL, new ItemStack(Material.DIAMOND), Map.of(), () -> { + attempts.incrementAndGet(); + sourceExists.set(false); + }).toCompletableFuture().join(); + + assertEquals(RetainedDeliveryStatus.COMMIT_FAILED, first.status()); + assertEquals(RetainedDeliveryStatus.COMMITTED, recovered.status()); + assertTrue(recovered.delivery().duplicate()); + assertFalse(sourceExists.get()); + assertEquals(2, attempts.get()); + assertEquals(2, delivery.calls); + assertEquals(1, delivery.uniqueGrants); + } + + private void attemptIfPresent(UUID noteId, + RetainedDeliveryKind kind, + AtomicBoolean sourceExists, + AtomicInteger commits) { + if (!sourceExists.get()) return; + coordinator.deliver(player, noteId, kind, new ItemStack(Material.DIAMOND), + Map.of(), () -> { + commits.incrementAndGet(); + sourceExists.set(false); + }).toCompletableFuture().join(); + } + + private static final class IdempotentDelivery implements AuctionItemDeliveryService { + private final Map operations = new HashMap<>(); + private boolean reject; + private int calls; + private int uniqueGrants; + + @Override + public CompletionStage deliver(Player recipient, + String operationId, + ItemStack item, + Map metadata) { + calls++; + if (reject) { + return CompletableFuture.completedFuture( + AuctionDeliveryResult.inventoryRejected( + operationId, item.getAmount(), false)); + } + String fingerprint = item.getType() + ":" + item.getAmount(); + String previous = operations.putIfAbsent(operationId, fingerprint); + if (previous != null && !previous.equals(fingerprint)) { + return CompletableFuture.completedFuture(new AuctionDeliveryResult( + operationId, AuctionDeliveryStatus.CONFLICT, item.getAmount(), + 0, 0, item.getAmount(), false, + AuctionDeliveryBackend.SKYBLOCK_CORE, false)); + } + boolean duplicate = previous != null; + if (!duplicate) uniqueGrants++; + return CompletableFuture.completedFuture(new AuctionDeliveryResult( + operationId, AuctionDeliveryStatus.COMPLETED, item.getAmount(), + item.getAmount(), 0, 0, duplicate, + AuctionDeliveryBackend.SKYBLOCK_CORE, false)); + } + + @Override + public AuctionDeliveryBackend backend() { + return AuctionDeliveryBackend.SKYBLOCK_CORE; + } + } + + private static final class ImmediateMainThread implements MainThreadExecutor { + @Override + public boolean isMainThread() { + return true; + } + + @Override + public void execute(Runnable action) { + action.run(); + } + } +} diff --git a/src/test/java/me/elaineqheart/auctionHouse/integration/ItemDeliveryIntegrationContractTest.java b/src/test/java/me/elaineqheart/auctionHouse/integration/ItemDeliveryIntegrationContractTest.java new file mode 100644 index 0000000..934b638 --- /dev/null +++ b/src/test/java/me/elaineqheart/auctionHouse/integration/ItemDeliveryIntegrationContractTest.java @@ -0,0 +1,61 @@ +package me.elaineqheart.auctionHouse.integration; + +import me.elaineqheart.auctionHouse.AuctionHouse; +import me.elaineqheart.auctionHouse.delivery.ItemDeliveryBootstrap; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ItemDeliveryIntegrationContractTest { + @Test + void resourcesExposeSoftDependencyConfigurationAndEveryChineseMessage() throws Exception { + String plugin = Files.readString(Path.of("src/main/resources/plugin.yml")); + String config = Files.readString(Path.of("src/main/resources/config.yml")); + String messages = Files.readString(Path.of("src/main/resources/messages.yml")); + + assertTrue(plugin.contains("Skyblock-Core")); + assertTrue(config.contains("integrations:")); + assertTrue(config.contains("source: 'auctionhouse'")); + assertTrue(messages.contains("物品已放入背包")); + assertTrue(messages.contains("背包已满,溢出物已进入 Stash")); + assertTrue(messages.contains("背包与 Stash 均无法接收,拍卖物品仍被安全保留")); + assertTrue(messages.contains("正在处理,请勿重复点击")); + assertTrue(messages.contains("Skyblock-Core API 不可用")); + assertTrue(messages.contains("交易状态不确定,需要管理员处理")); + } + + @Test + void requiredGuiPathsContainNoDirectInventoryDeliveryOrFirstEmptyGate() throws Exception { + Path base = Path.of("src/main/java/me/elaineqheart/auctionHouse/GUI/impl"); + for (String file : List.of("ConfirmBuyGUI.java", "CancelAuctionGUI.java", + "AuctionViewGUI.java", "CollectExpiredItemGUI.java", + "EndedAuctionGUI.java", "AdminConfirmGUI.java")) { + String source = Files.readString(base.resolve(file)); + assertFalse(source.contains("firstEmpty("), file); + assertFalse(source.contains("getInventory().addItem("), file); + } + } + + @Test + void startupClassesHaveNoStaticLinkToTheOptionalApi() throws Exception { + assertNoApiReference(AuctionHouse.class); + assertNoApiReference(ItemDeliveryBootstrap.class); + } + + private static void assertNoApiReference(Class type) throws Exception { + String resource = "/" + type.getName().replace('.', '/') + ".class"; + try (InputStream input = type.getResourceAsStream(resource)) { + byte[] bytes = input.readAllBytes(); + String constantPool = new String(bytes, StandardCharsets.ISO_8859_1); + assertFalse(constantPool.contains("dev/chengzhi/skyblockcore/api/delivery"), + type.getName()); + } + } +} diff --git a/src/test/java/me/elaineqheart/auctionHouse/integration/skyblockcore/SkyblockCoreHookTest.java b/src/test/java/me/elaineqheart/auctionHouse/integration/skyblockcore/SkyblockCoreHookTest.java new file mode 100644 index 0000000..a35ce5e --- /dev/null +++ b/src/test/java/me/elaineqheart/auctionHouse/integration/skyblockcore/SkyblockCoreHookTest.java @@ -0,0 +1,153 @@ +package me.elaineqheart.auctionHouse.integration.skyblockcore; + +import dev.chengzhi.skyblockcore.api.delivery.DeliveryRequest; +import dev.chengzhi.skyblockcore.api.delivery.DeliveryResult; +import dev.chengzhi.skyblockcore.api.delivery.DeliveryStatus; +import dev.chengzhi.skyblockcore.api.delivery.ItemDeliveryApi; +import me.elaineqheart.auctionHouse.delivery.AuctionDeliveryStatus; +import me.elaineqheart.auctionHouse.delivery.AuctionDeliveryResult; +import me.elaineqheart.auctionHouse.delivery.MainThreadExecutor; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkyblockCoreHookTest { + private ServerMock server; + private PlayerMock player; + private IdempotentApi api; + private SkyblockCoreHook hook; + + @BeforeEach + void setUp() { + server = MockBukkit.mock(); + player = server.addPlayer(); + Plugin plugin = MockBukkit.createMockPlugin(); + api = new IdempotentApi(); + server.getServicesManager().register( + ItemDeliveryApi.class, api, plugin, ServicePriority.Normal); + hook = new SkyblockCoreHook(plugin, "auctionhouse", new ImmediateMainThread()); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void mapsInventoryCompletion() { + api.destination = Destination.INVENTORY; + + AuctionDeliveryResult result = hook.deliver(player, "test.hook.inventory", + new ItemStack(Material.DIAMOND, 2), Map.of()).toCompletableFuture().join(); + + assertTrue(result.completed()); + assertEquals(2, result.inventoryAmount()); + assertEquals(0, result.stashedAmount()); + } + + @Test + void mapsFullInventoryOverflowToStash() { + api.destination = Destination.STASH; + + AuctionDeliveryResult result = hook.deliver(player, "test.hook.stash", + new ItemStack(Material.DIAMOND, 2), Map.of()).toCompletableFuture().join(); + + assertTrue(result.completed()); + assertEquals(0, result.inventoryAmount()); + assertEquals(2, result.stashedAmount()); + } + + @Test + void mapsExhaustedInventoryAndStashCapacityToRejection() { + api.destination = Destination.REJECT; + + AuctionDeliveryResult result = hook.deliver(player, "test.hook.reject", + new ItemStack(Material.DIAMOND, 2), Map.of()).toCompletableFuture().join(); + + assertEquals(AuctionDeliveryStatus.REJECTED, result.status()); + assertEquals(2, result.undeliveredAmount()); + assertEquals(0, api.uniqueGrants); + } + + @Test + void retryIsDuplicateCompletedButDifferentItemIsConflict() { + api.destination = Destination.STASH; + String operationId = "test.hook.idempotency"; + + AuctionDeliveryResult first = hook.deliver(player, operationId, + new ItemStack(Material.DIAMOND), Map.of()).toCompletableFuture().join(); + AuctionDeliveryResult duplicate = hook.deliver(player, operationId, + new ItemStack(Material.DIAMOND), Map.of()).toCompletableFuture().join(); + AuctionDeliveryResult conflict = hook.deliver(player, operationId, + new ItemStack(Material.EMERALD), Map.of()).toCompletableFuture().join(); + + assertTrue(first.completed()); + assertTrue(duplicate.completed()); + assertTrue(duplicate.duplicate()); + assertEquals(AuctionDeliveryStatus.CONFLICT, conflict.status()); + assertEquals(1, api.uniqueGrants); + } + + private enum Destination { INVENTORY, STASH, REJECT } + + private static final class IdempotentApi implements ItemDeliveryApi { + private final Map fingerprints = new HashMap<>(); + private Destination destination = Destination.INVENTORY; + private int uniqueGrants; + + @Override + public CompletionStage deliver(DeliveryRequest request) { + ItemStack item = request.items().getFirst(); + String fingerprint = item.getType() + ":" + item.getAmount() + ":" + item.serialize(); + String existing = fingerprints.get(request.operationKey()); + if (existing != null && !existing.equals(fingerprint)) { + return CompletableFuture.completedFuture(DeliveryResult.conflict( + request.operationKey(), request.requestedAmount())); + } + if (existing != null) { + return CompletableFuture.completedFuture(result(request).asDuplicate()); + } + fingerprints.put(request.operationKey(), fingerprint); + if (destination != Destination.REJECT) uniqueGrants++; + return CompletableFuture.completedFuture(result(request)); + } + + private DeliveryResult result(DeliveryRequest request) { + long amount = request.requestedAmount(); + return switch (destination) { + case INVENTORY -> new DeliveryResult(request.operationKey(), + DeliveryStatus.COMPLETED, amount, amount, 0, 0, false); + case STASH -> new DeliveryResult(request.operationKey(), + DeliveryStatus.COMPLETED, amount, 0, amount, 0, false); + case REJECT -> DeliveryResult.rejected(request.operationKey(), amount); + }; + } + } + + private static final class ImmediateMainThread implements MainThreadExecutor { + @Override + public boolean isMainThread() { + return true; + } + + @Override + public void execute(Runnable action) { + action.run(); + } + } +} diff --git a/src/test/java/me/elaineqheart/auctionHouse/purchase/JsonPurchaseSettlementStoreTest.java b/src/test/java/me/elaineqheart/auctionHouse/purchase/JsonPurchaseSettlementStoreTest.java new file mode 100644 index 0000000..de40e77 --- /dev/null +++ b/src/test/java/me/elaineqheart/auctionHouse/purchase/JsonPurchaseSettlementStoreTest.java @@ -0,0 +1,38 @@ +package me.elaineqheart.auctionHouse.purchase; + +import me.elaineqheart.auctionHouse.delivery.DeliveryOperationIds; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class JsonPurchaseSettlementStoreTest { + @TempDir + Path temporaryDirectory; + + @Test + void persistedSettlementRetainsItsUuidOperationAndStateAcrossReload() throws Exception { + UUID settlementId = UUID.randomUUID(); + PurchaseSettlement prepared = new PurchaseSettlement(settlementId, + UUID.randomUUID(), UUID.randomUUID(), "buyer", UUID.randomUUID(), + "seller", "base64-item-snapshot", 3, 42.5, + DeliveryOperationIds.purchase(settlementId), + PurchaseSettlementState.PREPARED, 1_765_000_000_000L, null); + JsonPurchaseSettlementStore first = new JsonPurchaseSettlementStore(temporaryDirectory); + first.save(prepared); + first.save(prepared.withState(PurchaseSettlementState.FUNDS_WITHDRAWN, null)); + + JsonPurchaseSettlementStore reloaded = new JsonPurchaseSettlementStore(temporaryDirectory); + PurchaseSettlement actual = reloaded.find(settlementId).orElseThrow(); + + assertEquals(settlementId, actual.settlementUUID()); + assertEquals(DeliveryOperationIds.purchase(settlementId), actual.operationId()); + assertEquals(PurchaseSettlementState.FUNDS_WITHDRAWN, actual.state()); + assertFalse(Files.exists(temporaryDirectory.resolve(settlementId + ".json.tmp"))); + } +} diff --git a/src/test/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementServiceTest.java b/src/test/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementServiceTest.java new file mode 100644 index 0000000..f38aa08 --- /dev/null +++ b/src/test/java/me/elaineqheart/auctionHouse/purchase/PurchaseSettlementServiceTest.java @@ -0,0 +1,377 @@ +package me.elaineqheart.auctionHouse.purchase; + +import me.elaineqheart.auctionHouse.data.persistentStorage.ItemStackConverter; +import me.elaineqheart.auctionHouse.delivery.AuctionDeliveryBackend; +import me.elaineqheart.auctionHouse.delivery.AuctionDeliveryResult; +import me.elaineqheart.auctionHouse.delivery.AuctionDeliveryStatus; +import me.elaineqheart.auctionHouse.delivery.AuctionItemDeliveryService; +import me.elaineqheart.auctionHouse.delivery.AuctionProcessingLocks; +import me.elaineqheart.auctionHouse.delivery.DeliveryOperationIds; +import me.elaineqheart.auctionHouse.delivery.MainThreadExecutor; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.io.IOException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PurchaseSettlementServiceTest { + private PlayerMock buyer; + private InMemoryStore store; + private FakeAuctionRepository auctions; + private FakeEconomy economy; + private FakeDelivery delivery; + private AuctionProcessingLocks locks; + private Queue ids; + private PurchaseSettlementService service; + + @BeforeEach + void setUp() { + MockBukkit.mock(); + buyer = MockBukkit.getMock().addPlayer("buyer"); + store = new InMemoryStore(); + auctions = new FakeAuctionRepository(); + economy = new FakeEconomy(); + delivery = new FakeDelivery(); + locks = new AuctionProcessingLocks(); + ids = new ArrayDeque<>(); + service = newService(); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void equalQuantityPurchasesOfTheSameNoteUseDifferentSettlementIds() { + UUID firstId = UUID.randomUUID(); + UUID secondId = UUID.randomUUID(); + ids.add(firstId); + ids.add(secondId); + + PurchaseResult first = service.purchase(buyer, auctions.noteId, 2) + .toCompletableFuture().join(); + PurchaseResult second = service.purchase(buyer, auctions.noteId, 2) + .toCompletableFuture().join(); + + assertTrue(first.committed()); + assertTrue(second.committed()); + assertNotEquals(first.settlement().settlementUUID(), + second.settlement().settlementUUID()); + assertEquals(DeliveryOperationIds.purchase(firstId), first.settlement().operationId()); + assertEquals(DeliveryOperationIds.purchase(secondId), second.settlement().operationId()); + assertEquals(2, economy.withdrawals); + assertEquals(2, delivery.uniqueGrants); + assertEquals(6, auctions.remaining); + } + + @Test + void doubleClickDoesNotDoubleChargeOrDoubleDeliver() { + UUID settlementId = UUID.randomUUID(); + ids.add(settlementId); + delivery.pending = true; + + CompletionStage first = service.purchase(buyer, auctions.noteId, 2); + PurchaseResult second = service.purchase(buyer, auctions.noteId, 2) + .toCompletableFuture().join(); + + assertEquals(PurchaseResultStatus.PROCESSING, second.status()); + assertEquals(1, economy.withdrawals); + assertEquals(1, delivery.calls); + + delivery.completePending(); + PurchaseResult completed = first.toCompletableFuture().join(); + assertTrue(completed.committed()); + assertEquals(1, economy.withdrawals); + assertEquals(1, delivery.uniqueGrants); + assertEquals(8, auctions.remaining); + } + + @Test + void nullVaultWithdrawalRequiresManualReconciliation() { + ids.add(UUID.randomUUID()); + economy.withdrawal = null; + + PurchaseResult result = service.purchase(buyer, auctions.noteId, 2) + .toCompletableFuture().join(); + + assertEquals(PurchaseResultStatus.RECONCILIATION_REQUIRED, result.status()); + assertEquals(PurchaseSettlementState.RECONCILIATION_REQUIRED, + result.settlement().state()); + assertTrue(locks.isLocked(auctions.noteId)); + assertEquals(0, delivery.calls); + assertEquals(10, auctions.remaining); + } + + @Test + void thrownVaultWithdrawalRequiresManualReconciliation() { + ids.add(UUID.randomUUID()); + economy.throwWithdrawal = true; + + PurchaseResult result = service.purchase(buyer, auctions.noteId, 2) + .toCompletableFuture().join(); + + assertEquals(PurchaseResultStatus.RECONCILIATION_REQUIRED, result.status()); + assertTrue(locks.isLocked(auctions.noteId)); + assertEquals(0, delivery.calls); + assertEquals(0, economy.refunds); + } + + @Test + void explicitDeliveryFailureRefundsAndRestoresTheReservation() { + ids.add(UUID.randomUUID()); + delivery.reject = true; + + PurchaseResult result = service.purchase(buyer, auctions.noteId, 3) + .toCompletableFuture().join(); + + assertEquals(PurchaseResultStatus.DELIVERY_FAILED, result.status()); + assertEquals(PurchaseSettlementState.ABORTED, result.settlement().state()); + assertEquals(1, economy.withdrawals); + assertEquals(1, economy.refunds); + assertEquals(10, auctions.remaining); + assertFalse(locks.isLocked(auctions.noteId)); + } + + @Test + void restartRetriesFundsWithdrawnWithThePersistedOperationId() throws IOException { + UUID settlementId = UUID.randomUUID(); + ItemStack item = new ItemStack(Material.DIAMOND, 2); + PurchaseSettlement persisted = settlement(settlementId, + PurchaseSettlementState.FUNDS_WITHDRAWN, item); + store.save(persisted); + delivery.preGrant(persisted.operationId(), item); + + List> resumed = service.recover(List.of(buyer)); + PurchaseResult result = resumed.getFirst().toCompletableFuture().join(); + + assertTrue(result.committed()); + assertEquals(settlementId, result.settlement().settlementUUID()); + assertEquals(persisted.operationId(), delivery.lastOperationId); + assertTrue(result.delivery().duplicate()); + assertEquals(1, delivery.uniqueGrants); + assertEquals(0, economy.withdrawals); + assertEquals(8, auctions.remaining); + } + + @Test + void recoveryFinalizesDeliveryCompletedWithoutDeliveringAgain() throws IOException { + UUID settlementId = UUID.randomUUID(); + PurchaseSettlement persisted = settlement(settlementId, + PurchaseSettlementState.DELIVERY_COMPLETED, + new ItemStack(Material.DIAMOND, 2)); + store.save(persisted); + + PurchaseResult result = service.recover(List.of()).getFirst() + .toCompletableFuture().join(); + + assertTrue(result.committed()); + assertEquals(0, delivery.calls); + assertEquals(8, auctions.remaining); + + PurchaseSettlementService secondRecovery = newService(); + assertTrue(secondRecovery.recover(List.of()).isEmpty()); + assertEquals(8, auctions.remaining); + } + + private PurchaseSettlementService newService() { + return new PurchaseSettlementService(store, auctions, economy, delivery, + locks, new ImmediateMainThread(), + () -> { + UUID next = ids.poll(); + return next == null ? UUID.randomUUID() : next; + }, + Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC), + Logger.getLogger("PurchaseSettlementServiceTest")); + } + + private PurchaseSettlement settlement(UUID settlementId, + PurchaseSettlementState state, + ItemStack item) { + return new PurchaseSettlement(settlementId, auctions.noteId, + buyer.getUniqueId(), buyer.getDisplayName(), auctions.sellerId, + "seller", ItemStackConverter.encode(item), item.getAmount(), + item.getAmount() * 10.0, DeliveryOperationIds.purchase(settlementId), + state, Instant.parse("2026-08-14T12:00:00Z").toEpochMilli(), null); + } + + private static final class InMemoryStore implements PurchaseSettlementStore { + private final Map data = new HashMap<>(); + + @Override + public void save(PurchaseSettlement settlement) throws IOException { + data.put(settlement.settlementUUID(), settlement); + } + + @Override + public Optional find(UUID settlementId) { + return Optional.ofNullable(data.get(settlementId)); + } + + @Override + public Collection all() { + return List.copyOf(data.values()); + } + } + + private static final class FakeAuctionRepository implements PurchaseAuctionRepository { + private final UUID noteId = UUID.randomUUID(); + private final UUID sellerId = UUID.randomUUID(); + private final Set committed = new HashSet<>(); + private int remaining = 10; + + @Override + public Optional findPurchasable(UUID requestedNote, int quantity) { + if (!noteId.equals(requestedNote) || quantity <= 0 || quantity > remaining) { + return Optional.empty(); + } + return Optional.of(new PurchaseListing(noteId, sellerId, "seller", + new ItemStack(Material.DIAMOND, quantity), quantity, quantity * 10.0)); + } + + @Override + public PurchaseCommitStatus commit(PurchaseSettlement settlement) { + if (committed.contains(settlement.settlementUUID())) { + return PurchaseCommitStatus.ALREADY_COMMITTED; + } + if (settlement.quantity() > remaining) return PurchaseCommitStatus.CONFLICT; + remaining -= settlement.quantity(); + committed.add(settlement.settlementUUID()); + return PurchaseCommitStatus.COMMITTED; + } + } + + private static final class FakeEconomy implements EconomyGateway { + private EconomyTransaction withdrawal = EconomyTransaction.success(); + private EconomyTransaction refund = EconomyTransaction.success(); + private boolean throwWithdrawal; + private int withdrawals; + private int refunds; + + @Override + public double balance(OfflinePlayer player) { + return 1_000; + } + + @Override + public EconomyTransaction withdraw(OfflinePlayer player, double amount) { + withdrawals++; + if (throwWithdrawal) throw new IllegalStateException("uncertain withdrawal"); + return withdrawal; + } + + @Override + public EconomyTransaction deposit(OfflinePlayer player, double amount) { + refunds++; + return refund; + } + } + + private static final class FakeDelivery implements AuctionItemDeliveryService { + private final Map operations = new HashMap<>(); + private boolean reject; + private boolean pending; + private CompletableFuture pendingResult; + private String pendingOperation; + private ItemStack pendingItem; + private int calls; + private int uniqueGrants; + private String lastOperationId; + + @Override + public CompletionStage deliver(Player recipient, + String operationId, + ItemStack item, + Map metadata) { + calls++; + lastOperationId = operationId; + if (reject) { + return CompletableFuture.completedFuture( + AuctionDeliveryResult.inventoryRejected( + operationId, item.getAmount(), false)); + } + if (pending) { + pendingOperation = operationId; + pendingItem = item.clone(); + pendingResult = new CompletableFuture<>(); + return pendingResult; + } + return CompletableFuture.completedFuture(complete(operationId, item)); + } + + void completePending() { + pending = false; + pendingResult.complete(complete(pendingOperation, pendingItem)); + } + + void preGrant(String operationId, ItemStack item) { + operations.put(operationId, fingerprint(item)); + uniqueGrants++; + } + + private AuctionDeliveryResult complete(String operationId, ItemStack item) { + String fingerprint = fingerprint(item); + String previous = operations.putIfAbsent(operationId, fingerprint); + if (previous != null && !previous.equals(fingerprint)) { + return new AuctionDeliveryResult(operationId, + AuctionDeliveryStatus.CONFLICT, item.getAmount(), + 0, 0, item.getAmount(), false, + AuctionDeliveryBackend.SKYBLOCK_CORE, false); + } + boolean duplicate = previous != null; + if (!duplicate) uniqueGrants++; + return new AuctionDeliveryResult(operationId, + AuctionDeliveryStatus.COMPLETED, item.getAmount(), + item.getAmount(), 0, 0, duplicate, + AuctionDeliveryBackend.SKYBLOCK_CORE, false); + } + + private static String fingerprint(ItemStack item) { + return item.getType() + ":" + item.getAmount() + ":" + item.serialize(); + } + + @Override + public AuctionDeliveryBackend backend() { + return AuctionDeliveryBackend.SKYBLOCK_CORE; + } + } + + private static final class ImmediateMainThread implements MainThreadExecutor { + @Override + public boolean isMainThread() { + return true; + } + + @Override + public void execute(Runnable action) { + action.run(); + } + } +}