From 9344390599fe310b2dc16c8064dec1d9b82566dd Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 5 Sep 2026 07:48:59 -0400 Subject: [PATCH] feat(quests): add atomic block delivery completion --- README.md | 2 +- knowledge/log.md | 7 + ...005-deliver-blocks-and-complete-a-quest.md | 24 +-- .../BukkitIssuerNotifier.java | 54 +++++++ .../BukkitQuestCompletionInventory.java | 92 ++++++++++++ .../spigotquestboard/IssuerNotification.java | 23 +++ .../dmg/spigotquestboard/IssuerNotifier.java | 7 + .../spigotquestboard/QuestBoardDialogUi.java | 59 +++++++- .../dmg/spigotquestboard/QuestClaim.java | 25 +++ .../dmg/spigotquestboard/QuestCommand.java | 38 ++++- .../dmg/spigotquestboard/QuestCompletion.java | 9 ++ .../QuestCompletionController.java | 57 +++++++ .../QuestCompletionGateway.java | 9 ++ .../QuestCompletionInventory.java | 19 +++ .../dmg/spigotquestboard/QuestService.java | 101 +++++++++++-- .../dmg/spigotquestboard/QuestState.java | 30 +++- .../SpigotQuestBoardPlugin.java | 12 +- .../spigotquestboard/YamlQuestRepository.java | 94 +++++++++--- .../BukkitIssuerNotifierTest.java | 79 ++++++++++ .../QuestBoardDialogUiTest.java | 31 ++++ .../spigotquestboard/QuestCommandTest.java | 33 ++++ .../QuestCompletionAtomicityTest.java | 118 +++++++++++++++ .../QuestCompletionControllerTest.java | 142 ++++++++++++++++++ .../QuestCompletionServiceTest.java | 37 +++++ .../YamlQuestRepositoryTest.java | 34 +++++ 25 files changed, 1087 insertions(+), 49 deletions(-) create mode 100644 src/main/java/games/dmg/spigotquestboard/BukkitIssuerNotifier.java create mode 100644 src/main/java/games/dmg/spigotquestboard/BukkitQuestCompletionInventory.java create mode 100644 src/main/java/games/dmg/spigotquestboard/IssuerNotification.java create mode 100644 src/main/java/games/dmg/spigotquestboard/IssuerNotifier.java create mode 100644 src/main/java/games/dmg/spigotquestboard/QuestClaim.java create mode 100644 src/main/java/games/dmg/spigotquestboard/QuestCompletion.java create mode 100644 src/main/java/games/dmg/spigotquestboard/QuestCompletionController.java create mode 100644 src/main/java/games/dmg/spigotquestboard/QuestCompletionGateway.java create mode 100644 src/main/java/games/dmg/spigotquestboard/QuestCompletionInventory.java create mode 100644 src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java create mode 100644 src/test/java/games/dmg/spigotquestboard/QuestCompletionAtomicityTest.java create mode 100644 src/test/java/games/dmg/spigotquestboard/QuestCompletionControllerTest.java create mode 100644 src/test/java/games/dmg/spigotquestboard/QuestCompletionServiceTest.java diff --git a/README.md b/README.md index 17f7f45..e753c9e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The approved behavior is specified in the [OKF knowledge bundle](knowledge/index ## Status -Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. Right-clicking any registered board opens a native dialog where a player can request a block and quantity while escrowing the exact reward stack held in their main hand. Every board and `/quests list` show the same active quests with requested blocks, rewards, issuers, and time remaining. Delivery and claims remain under development. +Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. Right-clicking any registered board opens a native dialog where a player can request a block and quantity while escrowing the exact reward stack held in their main hand. Every board and `/quests list` show the same active quests with requested blocks, rewards, issuers, and time remaining. Players can complete quests at any board or with `/quests complete ` by delivering the required blocks. Exact escrowed rewards are granted immediately, and delivered blocks are held for the issuer. Claim collection remains under development. ## Requirements diff --git a/knowledge/log.md b/knowledge/log.md index 5c64de1..84db598 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -47,3 +47,10 @@ description: Chronological record of material decisions affecting Spigot Quest B - Listings include requested blocks, exact reward summaries, issuer names, and boundary-safe remaining durations. - Added persisted lifecycle status with backward-compatible active defaults and filtered stale quest IDs from contextual completion and cancellation suggestions. - Verified 37 tests and the plugin JAR with `./gradlew clean check jar`. + +## 2026-09-05 — Block delivery and quest completion + +- Added board actions and `/quests complete ` for exact requested-material delivery from player inventories. +- Completion serializes state transitions, holds delivered blocks in durable issuer claims, and grants exact escrowed rewards with owner-protected overflow drops. +- Added durable online and next-login issuer notifications and rollback before persistence succeeds. +- Verified 49 tests and the plugin JAR with `./gradlew clean check jar`. diff --git a/knowledge/user-stories/us-005-deliver-blocks-and-complete-a-quest.md b/knowledge/user-stories/us-005-deliver-blocks-and-complete-a-quest.md index f45f65d..05c9bb7 100644 --- a/knowledge/user-stories/us-005-deliver-blocks-and-complete-a-quest.md +++ b/knowledge/user-stories/us-005-deliver-blocks-and-complete-a-quest.md @@ -2,7 +2,7 @@ type: User Story title: "US-005: Deliver blocks and complete a quest" description: Let a player deliver requested blocks at a quest board and receive the escrowed reward. -status: backlog +status: done --- # US-005: Deliver blocks and complete a quest @@ -11,17 +11,17 @@ As a **player**, I want to deliver requested blocks to a quest board so that I r ## Acceptance criteria -- [ ] An active quest offers a Complete Quest action through every registered board. -- [ ] Completion requires the delivering player to possess the full requested quantity of the exact block material. -- [ ] Successful completion removes the requested blocks from the delivering player's inventory and stores them as a claim for the issuer. -- [ ] Successful completion gives the exact escrowed reward items to the delivering player. -- [ ] Reward items that do not fit are dropped safely at the delivering player's feet with a clear message. -- [ ] The issuer is notified immediately when online or on their next login when offline that delivered blocks can be claimed at a quest board. -- [ ] When player commands are enabled, `/quests complete ` provides equivalent behavior with contextual autocomplete. -- [ ] Completion is atomic and exactly one player can complete a quest, including simultaneous attempts. -- [ ] Validation or persistence failure does not consume delivered blocks, release rewards, or change quest state. -- [ ] Completed quests are no longer shown as active or completable. -- [ ] Automated tests verify inventory validation, settlement, overflow, notifications, rollback, and concurrent completion protection. +- [x] An active quest offers a Complete Quest action through every registered board. +- [x] Completion requires the delivering player to possess the full requested quantity of the exact block material. +- [x] Successful completion removes the requested blocks from the delivering player's inventory and stores them as a claim for the issuer. +- [x] Successful completion gives the exact escrowed reward items to the delivering player. +- [x] Reward items that do not fit are dropped safely at the delivering player's feet with a clear message. +- [x] The issuer is notified immediately when online or on their next login when offline that delivered blocks can be claimed at a quest board. +- [x] When player commands are enabled, `/quests complete ` provides equivalent behavior with contextual autocomplete. +- [x] Completion is atomic and exactly one player can complete a quest, including simultaneous attempts. +- [x] Validation or persistence failure does not consume delivered blocks, release rewards, or change quest state. +- [x] Completed quests are no longer shown as active or completable. +- [x] Automated tests verify inventory validation, settlement, overflow, notifications, rollback, and concurrent completion protection. ## Related diff --git a/src/main/java/games/dmg/spigotquestboard/BukkitIssuerNotifier.java b/src/main/java/games/dmg/spigotquestboard/BukkitIssuerNotifier.java new file mode 100644 index 0000000..11e197a --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BukkitIssuerNotifier.java @@ -0,0 +1,54 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.util.Objects; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +final class BukkitIssuerNotifier implements IssuerNotifier, Listener { + private final QuestService quests; + private final Server server; + private final Logger logger; + + BukkitIssuerNotifier(QuestService quests, Server server, Logger logger) { + this.quests = Objects.requireNonNull(quests, "quests"); + this.server = Objects.requireNonNull(server, "server"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + @Override + public void notifyIfOnline(UUID issuerId) { + Player player = server.getPlayer(issuerId); + if (player != null && player.isOnline()) { + deliver(player); + } + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + deliver(event.getPlayer()); + } + + void deliver(Player player) { + for (IssuerNotification notification + : quests.pendingNotifications(player.getUniqueId())) { + player.sendMessage(notification.message()); + try { + quests.markNotificationDelivered(notification.id()); + } catch (IOException exception) { + logger.log( + Level.WARNING, + "Could not persist delivery of issuer notification " + notification.id() + + "; it will be retried", + exception + ); + } + } + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/BukkitQuestCompletionInventory.java b/src/main/java/games/dmg/spigotquestboard/BukkitQuestCompletionInventory.java new file mode 100644 index 0000000..38b5567 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BukkitQuestCompletionInventory.java @@ -0,0 +1,92 @@ +package games.dmg.spigotquestboard; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.bukkit.Material; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; + +final class BukkitQuestCompletionInventory implements QuestCompletionInventory { + @Override + public RemovedDelivery remove(Player player, String materialName, int amount) { + Objects.requireNonNull(player, "player"); + Material material = Objects.requireNonNull(Material.matchMaterial(materialName), "material"); + PlayerInventory inventory = player.getInventory(); + ItemStack[] contents = inventory.getStorageContents(); + int available = 0; + for (ItemStack stack : contents) { + if (stack != null && stack.getType() == material) { + available += stack.getAmount(); + } + } + if (available < amount) { + throw new IllegalArgumentException( + "You need exactly " + amount + " " + material.name() + + " to complete this quest (you have " + available + ")." + ); + } + + Map snapshots = new LinkedHashMap<>(); + List removedItems = new ArrayList<>(); + int remaining = amount; + for (int slot = 0; slot < contents.length && remaining > 0; slot++) { + ItemStack stack = contents[slot]; + if (stack == null || stack.getType() != material) { + continue; + } + snapshots.put(slot, stack.clone()); + int removedAmount = Math.min(stack.getAmount(), remaining); + ItemStack removed = stack.clone(); + removed.setAmount(removedAmount); + removedItems.add(EscrowItem.fromItemStack(removed)); + if (removedAmount == stack.getAmount()) { + inventory.clear(slot); + } else { + ItemStack reduced = stack.clone(); + reduced.setAmount(stack.getAmount() - removedAmount); + inventory.setItem(slot, reduced); + } + remaining -= removedAmount; + } + + return new RemovedDelivery() { + private boolean rolledBack; + + @Override + public List items() { + return List.copyOf(removedItems); + } + + @Override + public void rollback() { + if (rolledBack) { + return; + } + snapshots.forEach(inventory::setItem); + rolledBack = true; + } + }; + } + + @Override + public PreparedReward prepare(List reward) { + List stacks = reward.stream().map(EscrowItem::toItemStack).toList(); + return player -> { + int overflow = 0; + for (ItemStack stack : stacks) { + Map leftovers = player.getInventory().addItem(stack.clone()); + for (ItemStack leftover : leftovers.values()) { + Item dropped = player.getWorld().dropItem(player.getLocation(), leftover); + dropped.setOwner(player.getUniqueId()); + overflow += leftover.getAmount(); + } + } + return overflow; + }; + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/IssuerNotification.java b/src/main/java/games/dmg/spigotquestboard/IssuerNotification.java new file mode 100644 index 0000000..f2fb96a --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/IssuerNotification.java @@ -0,0 +1,23 @@ +package games.dmg.spigotquestboard; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +record IssuerNotification( + UUID id, + UUID questId, + UUID recipientId, + String message, + Instant createdAt +) { + IssuerNotification { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(questId, "questId"); + Objects.requireNonNull(recipientId, "recipientId"); + if (Objects.requireNonNull(message, "message").isBlank()) { + throw new IllegalArgumentException("Notification message must not be blank"); + } + Objects.requireNonNull(createdAt, "createdAt"); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/IssuerNotifier.java b/src/main/java/games/dmg/spigotquestboard/IssuerNotifier.java new file mode 100644 index 0000000..b753da7 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/IssuerNotifier.java @@ -0,0 +1,7 @@ +package games.dmg.spigotquestboard; + +import java.util.UUID; + +interface IssuerNotifier { + void notifyIfOnline(UUID issuerId); +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java index bec7392..3cddd19 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java @@ -10,6 +10,7 @@ import io.papermc.paper.registry.data.dialog.type.DialogType; import java.io.IOException; import java.time.Clock; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import net.kyori.adventure.text.Component; @@ -19,11 +20,22 @@ import org.bukkit.entity.Player; final class QuestBoardDialogUi implements QuestBoardUi { private final QuestCreationGateway creator; private final QuestBrowser browser; + private final QuestCompletionGateway completer; private final Clock clock; QuestBoardDialogUi(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { + this(creator, browser, null, clock); + } + + QuestBoardDialogUi( + QuestCreationGateway creator, + QuestBrowser browser, + QuestCompletionGateway completer, + Clock clock + ) { this.creator = Objects.requireNonNull(creator, "creator"); this.browser = Objects.requireNonNull(browser, "browser"); + this.completer = completer; this.clock = Objects.requireNonNull(clock, "clock"); } @@ -45,7 +57,16 @@ final class QuestBoardDialogUi implements QuestBoardUi { .lifetime(Duration.ofMinutes(10)) .build())) .build(); - String listing = listingText(); + java.time.Instant now = clock.instant(); + List activeQuests = browser.activeQuests(now); + String listing = QuestListingFormatter.formatAll(activeQuests, now); + List actions = new ArrayList<>(); + actions.add(create); + if (completer != null) { + for (Quest quest : activeQuests) { + actions.add(completionButton(quest)); + } + } DialogBase base = DialogBase.builder(Component.text("Quest Board")) .externalTitle(Component.text("Quest Board — Active quests and create")) .body(List.of(DialogBody.plainMessage(Component.text( @@ -69,7 +90,7 @@ final class QuestBoardDialogUi implements QuestBoardUi { .build(); player.showDialog(Dialog.create(factory -> factory.empty() .base(base) - .type(DialogType.notice(create)))); + .type(DialogType.multiAction(actions).columns(1).build()))); } String listingText() { @@ -77,6 +98,40 @@ final class QuestBoardDialogUi implements QuestBoardUi { return QuestListingFormatter.formatAll(browser.activeQuests(now), now); } + private ActionButton completionButton(Quest quest) { + String id = quest.id().toString(); + return ActionButton.builder(Component.text( + "Complete " + quest.requestedAmount() + " " + quest.requestedMaterial() + )) + .tooltip(Component.text("Deliver blocks for quest " + id)) + .width(250) + .action(DialogAction.customClick((response, audience) -> { + if (audience instanceof Player respondingPlayer) { + submitCompletion(respondingPlayer, id); + } + }, ClickCallback.Options.builder() + .uses(1) + .lifetime(Duration.ofMinutes(10)) + .build())) + .build(); + } + + void submitCompletion(Player player, String questId) { + if (completer == null) { + player.sendMessage("Quest completion is unavailable."); + return; + } + try { + completer.complete(player, questId, clock.instant()); + } catch (IllegalArgumentException | IllegalStateException exception) { + player.sendMessage(exception.getMessage()); + } catch (IOException exception) { + player.sendMessage( + "The quest could not be saved. Your delivered blocks were restored." + ); + } + } + void submit(Player player, String material, String quantityText) { final int quantity; try { diff --git a/src/main/java/games/dmg/spigotquestboard/QuestClaim.java b/src/main/java/games/dmg/spigotquestboard/QuestClaim.java new file mode 100644 index 0000000..6ca0855 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestClaim.java @@ -0,0 +1,25 @@ +package games.dmg.spigotquestboard; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +record QuestClaim( + UUID id, + UUID questId, + UUID ownerId, + List items, + Instant createdAt +) { + QuestClaim { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(questId, "questId"); + Objects.requireNonNull(ownerId, "ownerId"); + items = List.copyOf(Objects.requireNonNull(items, "items")); + if (items.isEmpty()) { + throw new IllegalArgumentException("Claim items must not be empty"); + } + Objects.requireNonNull(createdAt, "createdAt"); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java index 7e15740..96e2e72 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java @@ -16,11 +16,22 @@ final class QuestCommand implements CommandExecutor, TabCompleter { private static final List QUANTITIES = List.of("1", "16", "32", "64"); private final QuestCreationGateway creator; private final QuestBrowser browser; + private final QuestCompletionGateway completer; private final Clock clock; QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { + this(creator, browser, null, clock); + } + + QuestCommand( + QuestCreationGateway creator, + QuestBrowser browser, + QuestCompletionGateway completer, + Clock clock + ) { this.creator = Objects.requireNonNull(creator, "creator"); this.browser = Objects.requireNonNull(browser, "browser"); + this.completer = completer; this.clock = Objects.requireNonNull(clock, "clock"); } @@ -34,6 +45,26 @@ final class QuestCommand implements CommandExecutor, TabCompleter { sender.sendMessage(QuestListingFormatter.formatAll(browser.activeQuests(now), now)); return true; } + if (arguments.length == 2 && "complete".equalsIgnoreCase(arguments[0])) { + if (!(sender instanceof Player player)) { + sender.sendMessage("Only players can complete quests with inventory items."); + return true; + } + if (completer == null) { + sender.sendMessage("Quest completion is unavailable."); + return true; + } + try { + completer.complete(player, arguments[1], clock.instant()); + } catch (IllegalArgumentException | IllegalStateException exception) { + sender.sendMessage(exception.getMessage()); + } catch (IOException exception) { + sender.sendMessage( + "The quest could not be saved. Your delivered blocks were restored." + ); + } + return true; + } if (arguments.length != 3 || !"create".equalsIgnoreCase(arguments[0])) { usage(sender); return true; @@ -68,7 +99,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter { return List.of(); } if (arguments.length == 1) { - return startsWith(List.of("create", "list"), arguments[0]); + return startsWith(List.of("create", "list", "complete"), arguments[0]); } if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) { return creator.suggestBlockMaterials(arguments[1]); @@ -96,7 +127,10 @@ final class QuestCommand implements CommandExecutor, TabCompleter { } private static void usage(CommandSender sender) { - sender.sendMessage("Usage: /quests [list] | /quests create "); + sender.sendMessage( + "Usage: /quests [list] | /quests create | " + + "/quests complete " + ); sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed."); } } diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCompletion.java b/src/main/java/games/dmg/spigotquestboard/QuestCompletion.java new file mode 100644 index 0000000..9bdde84 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestCompletion.java @@ -0,0 +1,9 @@ +package games.dmg.spigotquestboard; + +import java.util.List; + +record QuestCompletion(Quest quest, QuestClaim claim, List reward) { + QuestCompletion { + reward = List.copyOf(reward); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCompletionController.java b/src/main/java/games/dmg/spigotquestboard/QuestCompletionController.java new file mode 100644 index 0000000..934fe49 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestCompletionController.java @@ -0,0 +1,57 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import org.bukkit.entity.Player; + +final class QuestCompletionController implements QuestCompletionGateway { + private final QuestService quests; + private final QuestCompletionInventory inventory; + private final IssuerNotifier notifier; + + QuestCompletionController( + QuestService quests, QuestCompletionInventory inventory, IssuerNotifier notifier + ) { + this.quests = Objects.requireNonNull(quests, "quests"); + this.inventory = Objects.requireNonNull(inventory, "inventory"); + this.notifier = Objects.requireNonNull(notifier, "notifier"); + } + + @Override + public QuestCompletion complete(Player player, String questId, Instant completedAt) + throws IOException { + Objects.requireNonNull(player, "player"); + final UUID id; + try { + id = UUID.fromString(Objects.requireNonNull(questId, "questId")); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("Quest identifier must be a valid UUID.", exception); + } + Quest quest = quests.completionCandidate(id, completedAt); + QuestCompletionInventory.PreparedReward reward = inventory.prepare(quest.reward()); + QuestCompletionInventory.RemovedDelivery delivery = inventory.remove( + player, quest.requestedMaterial(), quest.requestedAmount() + ); + final QuestCompletion completion; + try { + completion = quests.complete(id, delivery.items(), completedAt); + } catch (IOException | RuntimeException exception) { + delivery.rollback(); + throw exception; + } + + int overflow = reward.grantAndDropOverflow(player); + player.sendMessage( + "Quest " + id + " completed. You received the exact escrowed reward." + ); + if (overflow > 0) { + player.sendMessage( + overflow + " reward item(s) did not fit and were dropped safely at your feet." + ); + } + notifier.notifyIfOnline(completion.quest().issuerId()); + return completion; + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCompletionGateway.java b/src/main/java/games/dmg/spigotquestboard/QuestCompletionGateway.java new file mode 100644 index 0000000..9d97589 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestCompletionGateway.java @@ -0,0 +1,9 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.time.Instant; +import org.bukkit.entity.Player; + +interface QuestCompletionGateway { + QuestCompletion complete(Player player, String questId, Instant completedAt) throws IOException; +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCompletionInventory.java b/src/main/java/games/dmg/spigotquestboard/QuestCompletionInventory.java new file mode 100644 index 0000000..809bd0c --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestCompletionInventory.java @@ -0,0 +1,19 @@ +package games.dmg.spigotquestboard; + +import java.util.List; +import org.bukkit.entity.Player; + +interface QuestCompletionInventory { + RemovedDelivery remove(Player player, String material, int amount); + + PreparedReward prepare(List reward); + + interface RemovedDelivery { + List items(); + void rollback(); + } + + interface PreparedReward { + int grantAndDropOverflow(Player player); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestService.java b/src/main/java/games/dmg/spigotquestboard/QuestService.java index c214a07..3cfc677 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestService.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestService.java @@ -3,20 +3,21 @@ package games.dmg.spigotquestboard; import java.io.IOException; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.UUID; final class QuestService implements QuestBrowser { private final QuestRepository repository; - private Map quests; + private QuestState state; QuestService(QuestRepository repository) throws IOException { this.repository = Objects.requireNonNull(repository, "repository"); - quests = new LinkedHashMap<>(repository.load().quests()); + state = repository.load(); } synchronized Quest create( @@ -38,23 +39,105 @@ final class QuestService implements QuestBrowser { UUID.randomUUID(), issuerId, issuerName, material, requestedAmount, reward, createdAt, createdAt.plus(7, ChronoUnit.DAYS) ); - Map candidate = new LinkedHashMap<>(quests); - candidate.put(quest.id(), quest); - repository.save(new QuestState(candidate)); - quests = Map.copyOf(candidate); + Map quests = new LinkedHashMap<>(state.quests()); + quests.put(quest.id(), quest); + save(new QuestState(quests, state.claims(), state.notifications())); return quest; } + synchronized Quest completionCandidate(UUID questId, Instant now) { + Objects.requireNonNull(questId, "questId"); + Objects.requireNonNull(now, "now"); + Quest quest = state.quests().get(questId); + if (quest == null) { + throw new IllegalArgumentException("Quest not found: " + questId); + } + if (quest.status() != QuestStatus.ACTIVE || !now.isBefore(quest.expiresAt())) { + throw new IllegalStateException("That quest is no longer active or has expired."); + } + return quest; + } + + synchronized QuestCompletion complete( + UUID questId, EscrowItem delivered, Instant completedAt + ) throws IOException { + return complete(questId, List.of(delivered), completedAt); + } + + synchronized QuestCompletion complete( + UUID questId, List delivered, Instant completedAt + ) throws IOException { + Quest active = completionCandidate(questId, completedAt); + List delivery = List.copyOf(Objects.requireNonNull(delivered, "delivered")); + if (delivery.isEmpty() + || delivery.stream().anyMatch(item -> !item.material().equals(active.requestedMaterial())) + || delivery.stream().mapToInt(EscrowItem::amount).sum() != active.requestedAmount()) { + throw new IllegalArgumentException( + "Delivery must contain exactly " + active.requestedAmount() + " " + + active.requestedMaterial() + "." + ); + } + + Quest completed = new Quest( + active.id(), active.issuerId(), active.issuerName(), active.requestedMaterial(), + active.requestedAmount(), active.reward(), active.createdAt(), active.expiresAt(), + QuestStatus.COMPLETED + ); + QuestClaim claim = new QuestClaim( + UUID.randomUUID(), active.id(), active.issuerId(), delivery, completedAt + ); + IssuerNotification notification = new IssuerNotification( + UUID.randomUUID(), active.id(), active.issuerId(), + "Quest " + active.id() + " was completed. Your delivered " + + active.requestedMaterial() + " blocks can be claimed at a quest board.", + completedAt + ); + + Map quests = new LinkedHashMap<>(state.quests()); + quests.put(active.id(), completed); + Map> claims = new LinkedHashMap<>(state.claims()); + List ownerClaims = new ArrayList<>( + claims.getOrDefault(active.issuerId(), List.of()) + ); + ownerClaims.add(claim); + claims.put(active.issuerId(), ownerClaims); + Map notifications = new LinkedHashMap<>(state.notifications()); + notifications.put(notification.id(), notification); + save(new QuestState(quests, claims, notifications)); + return new QuestCompletion(completed, claim, completed.reward()); + } + + synchronized List pendingNotifications(UUID recipientId) { + Objects.requireNonNull(recipientId, "recipientId"); + return state.notifications().values().stream() + .filter(notification -> notification.recipientId().equals(recipientId)) + .toList(); + } + + synchronized void markNotificationDelivered(UUID notificationId) throws IOException { + if (!state.notifications().containsKey(notificationId)) { + return; + } + Map notifications = new LinkedHashMap<>(state.notifications()); + notifications.remove(notificationId); + save(new QuestState(state.quests(), state.claims(), notifications)); + } + @Override public synchronized List activeQuests(Instant now) { Objects.requireNonNull(now, "now"); - return quests.values().stream() + return state.quests().values().stream() .filter(quest -> quest.status() == QuestStatus.ACTIVE) .filter(quest -> now.isBefore(quest.expiresAt())) .toList(); } synchronized QuestState state() { - return new QuestState(quests); + return state; + } + + private void save(QuestState candidate) throws IOException { + repository.save(candidate); + state = candidate; } } diff --git a/src/main/java/games/dmg/spigotquestboard/QuestState.java b/src/main/java/games/dmg/spigotquestboard/QuestState.java index f2b98dd..88e9394 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestState.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestState.java @@ -1,10 +1,16 @@ package games.dmg.spigotquestboard; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; -record QuestState(Map quests) { +record QuestState( + Map quests, + Map> claims, + Map notifications +) { QuestState { quests = Map.copyOf(Objects.requireNonNull(quests, "quests")); for (Map.Entry entry : quests.entrySet()) { @@ -12,9 +18,29 @@ record QuestState(Map quests) { throw new IllegalArgumentException("Quest map key does not match quest id"); } } + Map> immutableClaims = new LinkedHashMap<>(); + for (Map.Entry> entry + : Objects.requireNonNull(claims, "claims").entrySet()) { + List ownerClaims = List.copyOf(entry.getValue()); + if (ownerClaims.stream().anyMatch(claim -> !claim.ownerId().equals(entry.getKey()))) { + throw new IllegalArgumentException("Claim map key does not match owner id"); + } + immutableClaims.put(entry.getKey(), ownerClaims); + } + claims = Map.copyOf(immutableClaims); + notifications = Map.copyOf(Objects.requireNonNull(notifications, "notifications")); + for (Map.Entry entry : notifications.entrySet()) { + if (!entry.getKey().equals(entry.getValue().id())) { + throw new IllegalArgumentException("Notification map key does not match id"); + } + } + } + + QuestState(Map quests) { + this(quests, Map.of(), Map.of()); } static QuestState empty() { - return new QuestState(Map.of()); + return new QuestState(Map.of(), Map.of(), Map.of()); } } diff --git a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java index b7c25b2..86a736d 100644 --- a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java +++ b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java @@ -31,12 +31,20 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin { QuestCreationGateway creator = new QuestCreationController( quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory() ); - QuestCommand questCommand = new QuestCommand(creator, quests, Clock.systemUTC()); + Clock clock = Clock.systemUTC(); + BukkitIssuerNotifier notifier = new BukkitIssuerNotifier( + quests, getServer(), getLogger() + ); + QuestCompletionGateway completer = new QuestCompletionController( + quests, new BukkitQuestCompletionInventory(), notifier + ); + QuestCommand questCommand = new QuestCommand(creator, quests, completer, clock); command("quests").setExecutor(questCommand); command("quests").setTabCompleter(questCommand); + getServer().getPluginManager().registerEvents(notifier, this); getServer().getPluginManager().registerEvents( new QuestBoardInteractionListener( - boards, new QuestBoardDialogUi(creator, quests, Clock.systemUTC()) + boards, new QuestBoardDialogUi(creator, quests, completer, clock) ), this ); diff --git a/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java b/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java index 4949364..6f5a8a8 100644 --- a/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java +++ b/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java @@ -34,6 +34,8 @@ final class YamlQuestRepository implements QuestRepository { throw new IOException("Invalid quest state", exception); } Map quests = new LinkedHashMap<>(); + Map> claims = new LinkedHashMap<>(); + Map notifications = new LinkedHashMap<>(); try { for (Map entry : yaml.getMapList("quests")) { UUID id = UUID.fromString(requiredString(entry, "id")); @@ -43,7 +45,7 @@ final class YamlQuestRepository implements QuestRepository { requiredString(entry, "issuer-name"), requiredString(entry, "requested-material"), requiredInteger(entry, "requested-amount"), - readRewards(entry.get("reward")), + readItems(entry.get("reward"), "reward"), Instant.parse(requiredString(entry, "created-at")), Instant.parse(requiredString(entry, "expires-at")), readStatus(entry.get("status")) @@ -52,10 +54,34 @@ final class YamlQuestRepository implements QuestRepository { throw new IllegalArgumentException("Duplicate quest id: " + id); } } + for (Map entry : yaml.getMapList("claims")) { + QuestClaim claim = new QuestClaim( + UUID.fromString(requiredString(entry, "id")), + UUID.fromString(requiredString(entry, "quest-id")), + UUID.fromString(requiredString(entry, "owner-id")), + readItems(entry.get("items"), "claim items"), + Instant.parse(requiredString(entry, "created-at")) + ); + claims.computeIfAbsent(claim.ownerId(), ignored -> new ArrayList<>()).add(claim); + } + for (Map entry : yaml.getMapList("notifications")) { + IssuerNotification notification = new IssuerNotification( + UUID.fromString(requiredString(entry, "id")), + UUID.fromString(requiredString(entry, "quest-id")), + UUID.fromString(requiredString(entry, "recipient-id")), + requiredString(entry, "message"), + Instant.parse(requiredString(entry, "created-at")) + ); + if (notifications.put(notification.id(), notification) != null) { + throw new IllegalArgumentException( + "Duplicate notification id: " + notification.id() + ); + } + } } catch (IllegalArgumentException | DateTimeParseException exception) { throw new IOException("Invalid quest record", exception); } - return new QuestState(quests); + return new QuestState(quests, claims, notifications); } @Override @@ -72,20 +98,36 @@ final class YamlQuestRepository implements QuestRepository { serialized.put("created-at", quest.createdAt().toString()); serialized.put("expires-at", quest.expiresAt().toString()); serialized.put("status", quest.status().name()); - List> rewards = new ArrayList<>(); - for (EscrowItem reward : quest.reward()) { - Map serializedReward = new LinkedHashMap<>(); - serializedReward.put("material", reward.material()); - serializedReward.put("amount", reward.amount()); - if (reward.serializedItem() != null) { - serializedReward.put("item-data", reward.serializedItem()); - } - rewards.add(serializedReward); - } - serialized.put("reward", rewards); + serialized.put("reward", writeItems(quest.reward())); serializedQuests.add(serialized); } yaml.set("quests", serializedQuests); + + List> serializedClaims = new ArrayList<>(); + for (List ownerClaims : state.claims().values()) { + for (QuestClaim claim : ownerClaims) { + Map serialized = new LinkedHashMap<>(); + serialized.put("id", claim.id().toString()); + serialized.put("quest-id", claim.questId().toString()); + serialized.put("owner-id", claim.ownerId().toString()); + serialized.put("created-at", claim.createdAt().toString()); + serialized.put("items", writeItems(claim.items())); + serializedClaims.add(serialized); + } + } + yaml.set("claims", serializedClaims); + + List> serializedNotifications = new ArrayList<>(); + for (IssuerNotification notification : state.notifications().values()) { + Map serialized = new LinkedHashMap<>(); + serialized.put("id", notification.id().toString()); + serialized.put("quest-id", notification.questId().toString()); + serialized.put("recipient-id", notification.recipientId().toString()); + serialized.put("message", notification.message()); + serialized.put("created-at", notification.createdAt().toString()); + serializedNotifications.add(serialized); + } + yaml.set("notifications", serializedNotifications); writeAtomically(yaml); } @@ -106,24 +148,38 @@ final class YamlQuestRepository implements QuestRepository { } } - private static List readRewards(Object value) { + private static List readItems(Object value, String description) { if (!(value instanceof List entries) || entries.isEmpty()) { - throw new IllegalArgumentException("Missing reward"); + throw new IllegalArgumentException("Missing " + description); } - List rewards = new ArrayList<>(); + List items = new ArrayList<>(); for (Object rawEntry : entries) { if (!(rawEntry instanceof Map entry)) { - throw new IllegalArgumentException("Invalid reward"); + throw new IllegalArgumentException("Invalid " + description); } Object data = entry.get("item-data"); if (data != null && !(data instanceof String)) { throw new IllegalArgumentException("Invalid item-data"); } - rewards.add(new EscrowItem( + items.add(new EscrowItem( requiredString(entry, "material"), requiredInteger(entry, "amount"), (String) data )); } - return rewards; + return items; + } + + private static List> writeItems(List items) { + List> serializedItems = new ArrayList<>(); + for (EscrowItem item : items) { + Map serialized = new LinkedHashMap<>(); + serialized.put("material", item.material()); + serialized.put("amount", item.amount()); + if (item.serializedItem() != null) { + serialized.put("item-data", item.serializedItem()); + } + serializedItems.add(serialized); + } + return serializedItems; } private static QuestStatus readStatus(Object value) { diff --git a/src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java b/src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java new file mode 100644 index 0000000..63b8454 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java @@ -0,0 +1,79 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.logging.Logger; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +final class BukkitIssuerNotifierTest { + @Test + void onlineIssuerIsNotifiedImmediatelyAndDurablePendingStateIsCleared() throws Exception { + Repository repository = new Repository(); + QuestService service = completedService(repository); + UUID issuer = service.state().notifications().values().iterator().next().recipientId(); + Server server = mock(Server.class); + Player player = mock(Player.class); + when(server.getPlayer(issuer)).thenReturn(player); + when(player.isOnline()).thenReturn(true); + when(player.getUniqueId()).thenReturn(issuer); + BukkitIssuerNotifier notifier = new BukkitIssuerNotifier( + service, server, Logger.getAnonymousLogger() + ); + + notifier.notifyIfOnline(issuer); + + verify(player).sendMessage(contains("can be claimed at a quest board")); + assertTrue(service.state().notifications().isEmpty()); + assertTrue(repository.state.notifications().isEmpty()); + } + + @Test + void offlineNotificationRemainsDurableUntilNextLogin() throws Exception { + Repository repository = new Repository(); + QuestService service = completedService(repository); + IssuerNotification notification = service.state().notifications().values().iterator().next(); + Server server = mock(Server.class); + when(server.getPlayer(notification.recipientId())).thenReturn(null); + BukkitIssuerNotifier notifier = new BukkitIssuerNotifier( + service, server, Logger.getAnonymousLogger() + ); + + notifier.notifyIfOnline(notification.recipientId()); + assertEquals(1, service.state().notifications().size()); + + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(notification.recipientId()); + notifier.deliver(player); + + verify(player).sendMessage(notification.message()); + assertTrue(service.state().notifications().isEmpty()); + } + + private static QuestService completedService(Repository repository) throws Exception { + QuestService service = new QuestService(repository); + Quest quest = service.create( + UUID.randomUUID(), "Issuer", "STONE", 1, + List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH + ); + service.complete( + quest.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1) + ); + return service; + } + + private static final class Repository implements QuestRepository { + private QuestState state = QuestState.empty(); + @Override public QuestState load() { return state; } + @Override public void save(QuestState candidate) { state = candidate; } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java index af13f66..fdb071b 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java @@ -35,6 +35,23 @@ final class QuestBoardDialogUiTest { ); } + @Test + void completionActionUsesEquivalentCompletionFlow() { + RecordingCreator creator = new RecordingCreator(false); + RecordingCompleter completer = new RecordingCompleter(); + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(), completer, Clock.fixed(NOW, ZoneOffset.UTC) + ); + Player player = mock(Player.class); + String id = UUID.randomUUID().toString(); + + ui.submitCompletion(player, id); + + assertEquals(player, completer.player); + assertEquals(id, completer.questId); + assertEquals(NOW, completer.completedAt); + } + @Test void everyOpeningBuildsAListingFromTheCurrentActiveQuests() { RecordingCreator creator = new RecordingCreator(false); @@ -60,6 +77,20 @@ final class QuestBoardDialogUiTest { ); } + private static final class RecordingCompleter implements QuestCompletionGateway { + private Player player; + private String questId; + private Instant completedAt; + + @Override + public QuestCompletion complete(Player completingPlayer, String id, Instant instant) { + player = completingPlayer; + questId = id; + completedAt = instant; + return null; + } + } + private static final class RecordingCreator implements QuestCreationGateway { private final boolean fail; private String material; diff --git a/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java index a3cb3a1..29dd1f6 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java @@ -86,6 +86,25 @@ final class QuestCommandTest { verify(player).sendMessage("Quest quantity must be a positive whole number."); } + @Test + void completeCommandRoutesPlayerAndCurrentTimeToEquivalentGateway() { + RecordingCreator creator = new RecordingCreator(); + RecordingCompleter completer = new RecordingCompleter(); + QuestCommand executor = new QuestCommand( + creator, now -> List.of(), completer, Clock.fixed(NOW, ZoneOffset.UTC) + ); + Player player = mock(Player.class); + String id = UUID.randomUUID().toString(); + + assertTrue(executor.onCommand( + player, mock(Command.class), "quests", new String[] {"complete", id} + )); + + assertEquals(player, completer.player); + assertEquals(id, completer.questId); + assertEquals(NOW, completer.completedAt); + } + @Test void questIdentifierAutocompleteOnlyUsesActiveAndOwnedQuests() { RecordingCreator creator = new RecordingCreator(); @@ -140,6 +159,20 @@ final class QuestCommandTest { return new QuestCommand(creator, browser, Clock.fixed(NOW, ZoneOffset.UTC)); } + private static final class RecordingCompleter implements QuestCompletionGateway { + private Player player; + private String questId; + private Instant completedAt; + + @Override + public QuestCompletion complete(Player completingPlayer, String id, Instant instant) { + player = completingPlayer; + questId = id; + completedAt = instant; + return null; + } + } + private static final class RecordingCreator implements QuestCreationGateway { private Player player; private String material; diff --git a/src/test/java/games/dmg/spigotquestboard/QuestCompletionAtomicityTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCompletionAtomicityTest.java new file mode 100644 index 0000000..d584f14 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestCompletionAtomicityTest.java @@ -0,0 +1,118 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; + +final class QuestCompletionAtomicityTest { + @Test + void persistenceFailureChangesNoDurableOrInMemoryState() throws Exception { + FailingRepository repository = new FailingRepository(); + QuestService service = new QuestService(repository); + Quest quest = service.create( + UUID.randomUUID(), "Issuer", "STONE", 2, + List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH + ); + QuestState before = service.state(); + repository.fail = true; + + assertThrows(IOException.class, () -> service.complete( + quest.id(), new EscrowItem("STONE", 2, null), Instant.EPOCH.plusSeconds(1) + )); + + assertEquals(before, service.state()); + assertEquals(before, repository.state); + } + + @Test + void rejectsWrongMaterialQuantityExpiredAndAlreadyCompleted() throws Exception { + FailingRepository repository = new FailingRepository(); + QuestService service = new QuestService(repository); + Quest quest = service.create( + UUID.randomUUID(), "Issuer", "STONE", 2, + List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH + ); + + assertThrows(IllegalArgumentException.class, () -> service.complete( + quest.id(), new EscrowItem("DIRT", 2, null), Instant.EPOCH.plusSeconds(1) + )); + assertThrows(IllegalArgumentException.class, () -> service.complete( + quest.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1) + )); + assertThrows(IllegalStateException.class, () -> service.complete( + quest.id(), new EscrowItem("STONE", 2, null), quest.expiresAt() + )); + service.complete( + quest.id(), new EscrowItem("STONE", 2, null), Instant.EPOCH.plusSeconds(1) + ); + assertThrows(IllegalStateException.class, () -> service.complete( + quest.id(), new EscrowItem("STONE", 2, null), Instant.EPOCH.plusSeconds(2) + )); + assertTrue(service.activeQuests(Instant.EPOCH.plusSeconds(2)).isEmpty()); + } + + @Test + void simultaneousAttemptsSettleExactlyOnce() throws Exception { + FailingRepository repository = new FailingRepository(); + QuestService service = new QuestService(repository); + UUID issuer = UUID.randomUUID(); + Quest quest = service.create( + issuer, "Issuer", "STONE", 1, + List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH + ); + CountDownLatch start = new CountDownLatch(1); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + List> attempts = new ArrayList<>(); + for (int index = 0; index < 2; index++) { + attempts.add(executor.submit(() -> { + start.await(); + try { + service.complete( + quest.id(), new EscrowItem("STONE", 1, null), + Instant.EPOCH.plusSeconds(1) + ); + return true; + } catch (IllegalStateException exception) { + return false; + } + })); + } + start.countDown(); + assertEquals(1, attempts.stream().filter(attempt -> { + try { + return attempt.get(); + } catch (Exception exception) { + throw new AssertionError(exception); + } + }).count()); + } + assertEquals(1, service.state().claims().get(issuer).size()); + assertEquals(1, service.state().notifications().size()); + } + + private static final class FailingRepository implements QuestRepository { + private QuestState state = QuestState.empty(); + private boolean fail; + + @Override public QuestState load() { return state; } + + @Override + public void save(QuestState candidate) throws IOException { + if (fail) { + throw new IOException("disk full"); + } + state = candidate; + } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestCompletionControllerTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCompletionControllerTest.java new file mode 100644 index 0000000..d501fad --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestCompletionControllerTest.java @@ -0,0 +1,142 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +final class QuestCompletionControllerTest { + @Test + void removesExactDeliverySettlesRewardReportsOverflowAndNotifies() throws Exception { + Repository repository = new Repository(); + QuestService service = new QuestService(repository); + UUID issuer = UUID.randomUUID(); + Quest quest = quest(service, issuer); + Inventory inventory = new Inventory(); + inventory.overflow = 2; + RecordingNotifier notifier = new RecordingNotifier(); + QuestCompletionController controller = new QuestCompletionController( + service, inventory, notifier + ); + Player player = mock(Player.class); + + controller.complete(player, quest.id().toString(), Instant.EPOCH.plusSeconds(1)); + + assertEquals("STONE", inventory.material); + assertEquals(3, inventory.amount); + assertTrue(inventory.granted); + assertFalse(inventory.rolledBack); + assertEquals(issuer, notifier.issuer); + assertEquals( + List.of(new EscrowItem("STONE", 3, null)), + service.state().claims().get(issuer).getFirst().items() + ); + verify(player).sendMessage(contains("exact escrowed reward")); + verify(player).sendMessage(contains("dropped safely at your feet")); + } + + @Test + void inventoryValidationFailureConsumesAndReleasesNothing() throws Exception { + Repository repository = new Repository(); + QuestService service = new QuestService(repository); + Quest quest = quest(service, UUID.randomUUID()); + Inventory inventory = new Inventory(); + inventory.insufficient = true; + Player player = mock(Player.class); + + assertThrows(IllegalArgumentException.class, () -> new QuestCompletionController( + service, inventory, ignored -> { } + ).complete(player, quest.id().toString(), Instant.EPOCH.plusSeconds(1))); + + assertFalse(inventory.granted); + assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status()); + assertTrue(service.state().claims().isEmpty()); + verify(player, never()).sendMessage(contains("completed")); + } + + @Test + void persistenceFailureRollsBackDeliveryAndDoesNotGrantReward() throws Exception { + Repository repository = new Repository(); + QuestService service = new QuestService(repository); + Quest quest = quest(service, UUID.randomUUID()); + Inventory inventory = new Inventory(); + repository.fail = true; + + assertThrows(IOException.class, () -> new QuestCompletionController( + service, inventory, ignored -> { } + ).complete(mock(Player.class), quest.id().toString(), Instant.EPOCH.plusSeconds(1))); + + assertTrue(inventory.rolledBack); + assertFalse(inventory.granted); + assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status()); + } + + private static Quest quest(QuestService service, UUID issuer) throws IOException { + return service.create( + issuer, "Issuer", "STONE", 3, + List.of(new EscrowItem("DIAMOND", 2, null)), Instant.EPOCH + ); + } + + private static final class Inventory implements QuestCompletionInventory { + private String material; + private int amount; + private boolean insufficient; + private boolean rolledBack; + private boolean granted; + private int overflow; + + @Override + public RemovedDelivery remove(Player player, String requestedMaterial, int requestedAmount) { + material = requestedMaterial; + amount = requestedAmount; + if (insufficient) { + throw new IllegalArgumentException("not enough blocks"); + } + return new RemovedDelivery() { + @Override + public List items() { + return List.of(new EscrowItem(requestedMaterial, requestedAmount, null)); + } + + @Override public void rollback() { rolledBack = true; } + }; + } + + @Override + public PreparedReward prepare(List reward) { + return player -> { + granted = true; + return overflow; + }; + } + } + + private static final class RecordingNotifier implements IssuerNotifier { + private UUID issuer; + @Override public void notifyIfOnline(UUID issuerId) { issuer = issuerId; } + } + + private static final class Repository implements QuestRepository { + private QuestState state = QuestState.empty(); + private boolean fail; + @Override public QuestState load() { return state; } + @Override public void save(QuestState candidate) throws IOException { + if (fail) { + throw new IOException("disk full"); + } + state = candidate; + } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestCompletionServiceTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCompletionServiceTest.java new file mode 100644 index 0000000..be7ea41 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestCompletionServiceTest.java @@ -0,0 +1,37 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class QuestCompletionServiceTest { + @Test + void completesOnceAndHoldsDeliveredBlocksForIssuer() throws Exception { + MemoryQuestRepository repository = new MemoryQuestRepository(); + QuestService service = new QuestService(repository); + UUID issuer = UUID.randomUUID(); + Quest quest = service.create( + issuer, "Issuer", "STONE", 64, + List.of(new EscrowItem("DIAMOND", 3, null)), Instant.EPOCH + ); + + QuestCompletion completion = service.complete( + quest.id(), new EscrowItem("STONE", 64, null), Instant.EPOCH.plusSeconds(1) + ); + + assertEquals(QuestStatus.COMPLETED, service.state().quests().get(quest.id()).status()); + assertEquals(quest.reward(), completion.reward()); + assertTrue(service.state().claims().get(issuer).stream() + .anyMatch(claim -> claim.items().equals(List.of(new EscrowItem("STONE", 64, null))))); + } + + private static final class MemoryQuestRepository implements QuestRepository { + private QuestState state = QuestState.empty(); + @Override public QuestState load() { return state; } + @Override public void save(QuestState state) { this.state = state; } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java b/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java index e704f4e..a8d7f52 100644 --- a/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java +++ b/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java @@ -93,6 +93,40 @@ final class YamlQuestRepositoryTest { assertTrue(Files.readString(path).contains("status: COMPLETED")); } + @Test + void roundTripsClaimsAndPendingNotificationsWhileOldFilesDefaultThemEmpty() throws Exception { + Path path = temporaryDirectory.resolve("quests.yml"); + YamlQuestRepository repository = new YamlQuestRepository(path); + UUID issuer = UUID.randomUUID(); + UUID questId = UUID.randomUUID(); + Quest quest = new Quest( + questId, issuer, "Issuer", "STONE", 2, + List.of(new EscrowItem("DIAMOND", 1, null)), + Instant.parse("2026-09-05T03:00:00Z"), + Instant.parse("2026-09-12T03:00:00Z"), QuestStatus.COMPLETED + ); + QuestClaim claim = new QuestClaim( + UUID.randomUUID(), questId, issuer, + List.of(new EscrowItem("STONE", 2, null)), + Instant.parse("2026-09-05T03:01:00Z") + ); + IssuerNotification notification = new IssuerNotification( + UUID.randomUUID(), questId, issuer, "Your delivery can be claimed.", + Instant.parse("2026-09-05T03:01:00Z") + ); + QuestState expected = new QuestState( + Map.of(questId, quest), Map.of(issuer, List.of(claim)), + Map.of(notification.id(), notification) + ); + + repository.save(expected); + + assertEquals(expected, repository.load()); + String yaml = Files.readString(path); + assertTrue(yaml.contains("claims:")); + assertTrue(yaml.contains("notifications:")); + } + @Test void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception { Path path = temporaryDirectory.resolve("quests.yml");