diff --git a/README.md b/README.md index d4b35d4..5b2c330 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. 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. Issuers can cancel their own active quests at any board or with `/quests cancel `; exact rewards are held for later claim collection. Claim collection remains 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. Issuers can cancel their own active quests at any board or with `/quests cancel `. Completed deliveries and rewards from cancelled or seven-day-expired quests are held durably and can be collected at any board or with `/quests claim`; inventory overflow drops at the claimant's feet. ## Requirements diff --git a/knowledge/log.md b/knowledge/log.md index 64e497c..4788792 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -61,3 +61,11 @@ description: Chronological record of material decisions affecting Spigot Quest B - Cancellation atomically moves exact reward escrow into an issuer claim without directly changing inventory. - Serialized cancellation against completion and retained active state when persistence fails. - Verified 58 tests and the plugin JAR with `./gradlew clean check jar`. + +## 2026-09-05 — Quest expiration and held-item claims + +- Added boundary-safe scheduled expiration that moves exact rewards into durable issuer claims. +- Added claim collection at every board and through `/quests claim`, distinguishing delivered blocks from cancelled or expired reward returns. +- Inventory overflow drops at the claimant's feet with ownership protection, and failed claim acknowledgement rolls inventory and drops back before retry. +- Added persisted online and next-login expiry notifications. +- Verified 73 tests and the plugin JAR with `./gradlew clean check jar`. diff --git a/knowledge/user-stories/us-007-expire-quests-and-claim-held-items.md b/knowledge/user-stories/us-007-expire-quests-and-claim-held-items.md index a842f3a..63bb070 100644 --- a/knowledge/user-stories/us-007-expire-quests-and-claim-held-items.md +++ b/knowledge/user-stories/us-007-expire-quests-and-claim-held-items.md @@ -2,7 +2,7 @@ type: User Story title: "US-007: Expire quests and claim held items" description: Hold delivered blocks and returned rewards until their owner safely claims them. -status: backlog +status: done --- # US-007: Expire quests and claim held items @@ -11,18 +11,18 @@ As a **quest issuer**, I want delivered blocks and returned rewards held at the ## Acceptance criteria -- [ ] An uncompleted quest expires seven days after its creation time. -- [ ] Expiration removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer. -- [ ] Completed-quest claims contain the exact block items delivered by the completing player. -- [ ] Claims from completion, cancellation, and expiration can be collected through any registered board. -- [ ] The board clearly distinguishes delivered blocks from returned rewards. -- [ ] Online issuers receive a real-time message when items become claimable. -- [ ] Offline issuers receive a message on their next login. -- [ ] When player commands are enabled, `/quests claim` collects the same pending claims. -- [ ] Items are inserted into the claimant's inventory where space permits; overflow is dropped safely at their feet with a clear message. -- [ ] A claim is removed only after all of its items have been inserted or dropped successfully. -- [ ] Pending claims and notification state survive logout and server restart without duplication or loss. -- [ ] Automated tests verify expiry boundaries, each claim source, notifications, overflow, failure recovery, and persistence. +- [x] An uncompleted quest expires seven days after its creation time. +- [x] Expiration removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer. +- [x] Completed-quest claims contain the exact block items delivered by the completing player. +- [x] Claims from completion, cancellation, and expiration can be collected through any registered board. +- [x] The board clearly distinguishes delivered blocks from returned rewards. +- [x] Online issuers receive a real-time message when items become claimable. +- [x] Offline issuers receive a message on their next login. +- [x] When player commands are enabled, `/quests claim` collects the same pending claims. +- [x] Items are inserted into the claimant's inventory where space permits; overflow is dropped safely at their feet with a clear message. +- [x] A claim is removed only after all of its items have been inserted or dropped successfully. +- [x] Pending claims and notification state survive logout and server restart without duplication or loss. +- [x] Automated tests verify expiry boundaries, each claim source, notifications, overflow, failure recovery, and persistence. ## Related diff --git a/src/main/java/games/dmg/spigotquestboard/BukkitQuestClaimInventory.java b/src/main/java/games/dmg/spigotquestboard/BukkitQuestClaimInventory.java new file mode 100644 index 0000000..ac60c90 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BukkitQuestClaimInventory.java @@ -0,0 +1,66 @@ +package games.dmg.spigotquestboard; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; + +final class BukkitQuestClaimInventory implements QuestClaimInventory { + @Override + public PreparedClaim prepare(List items) { + List stacks = items.stream().map(EscrowItem::toItemStack).toList(); + return player -> deliver(player, stacks); + } + + static Delivery deliver(Player player, List stacks) { + PlayerInventory inventory = player.getInventory(); + ItemStack[] snapshot = cloneContents(inventory.getStorageContents()); + List drops = new ArrayList<>(); + int overflow = 0; + try { + for (ItemStack stack : stacks) { + Map leftovers = inventory.addItem(stack.clone()); + for (ItemStack leftover : leftovers.values()) { + Item drop = player.getWorld().dropItem(player.getLocation(), leftover.clone()); + drops.add(drop); + drop.setOwner(player.getUniqueId()); + overflow += leftover.getAmount(); + } + } + } catch (RuntimeException exception) { + inventory.setStorageContents(cloneContents(snapshot)); + drops.forEach(Item::remove); + throw exception; + } + int deliveredOverflow = overflow; + return new Delivery() { + private boolean rolledBack; + + @Override + public int overflowAmount() { + return deliveredOverflow; + } + + @Override + public void rollback() { + if (rolledBack) { + return; + } + inventory.setStorageContents(cloneContents(snapshot)); + drops.forEach(Item::remove); + rolledBack = true; + } + }; + } + + private static ItemStack[] cloneContents(ItemStack[] contents) { + ItemStack[] copy = new ItemStack[contents.length]; + for (int index = 0; index < contents.length; index++) { + copy[index] = contents[index] == null ? null : contents[index].clone(); + } + return copy; + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/ClaimCollectionResult.java b/src/main/java/games/dmg/spigotquestboard/ClaimCollectionResult.java new file mode 100644 index 0000000..8768cb7 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/ClaimCollectionResult.java @@ -0,0 +1,4 @@ +package games.dmg.spigotquestboard; + +record ClaimCollectionResult(int claimsCollected, int overflowItems) { +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java index a6456d5..59ff47f 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java @@ -22,10 +22,11 @@ final class QuestBoardDialogUi implements QuestBoardUi { private final QuestBrowser browser; private final QuestCompletionGateway completer; private final QuestCancellationGateway canceller; + private final QuestClaimGateway claimant; private final Clock clock; QuestBoardDialogUi(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { - this(creator, browser, null, null, clock); + this(creator, browser, null, null, null, clock); } QuestBoardDialogUi( @@ -34,7 +35,7 @@ final class QuestBoardDialogUi implements QuestBoardUi { QuestCompletionGateway completer, Clock clock ) { - this(creator, browser, completer, null, clock); + this(creator, browser, completer, null, null, clock); } QuestBoardDialogUi( @@ -43,11 +44,23 @@ final class QuestBoardDialogUi implements QuestBoardUi { QuestCompletionGateway completer, QuestCancellationGateway canceller, Clock clock + ) { + this(creator, browser, completer, canceller, null, clock); + } + + QuestBoardDialogUi( + QuestCreationGateway creator, + QuestBrowser browser, + QuestCompletionGateway completer, + QuestCancellationGateway canceller, + QuestClaimGateway claimant, + Clock clock ) { this.creator = Objects.requireNonNull(creator, "creator"); this.browser = Objects.requireNonNull(browser, "browser"); this.completer = completer; this.canceller = canceller; + this.claimant = claimant; this.clock = Objects.requireNonNull(clock, "clock"); } @@ -72,8 +85,23 @@ final class QuestBoardDialogUi implements QuestBoardUi { java.time.Instant now = clock.instant(); List activeQuests = browser.activeQuests(now); String listing = QuestListingFormatter.formatAll(activeQuests, now); + String claimListing = claimListingText(player); List actions = new ArrayList<>(); actions.add(create); + if (claimant != null) { + actions.add(ActionButton.builder(Component.text("Collect pending claims")) + .tooltip(Component.text("Collect delivered blocks and returned rewards")) + .width(250) + .action(DialogAction.customClick((response, audience) -> { + if (audience instanceof Player respondingPlayer) { + submitClaim(respondingPlayer); + } + }, ClickCallback.Options.builder() + .uses(1) + .lifetime(Duration.ofMinutes(10)) + .build())) + .build()); + } for (Quest quest : activeQuests) { if (completer != null) { actions.add(completionButton(quest)); @@ -85,7 +113,8 @@ final class QuestBoardDialogUi implements QuestBoardUi { DialogBase base = DialogBase.builder(Component.text("Quest Board")) .externalTitle(Component.text("Quest Board — Active quests and create")) .body(List.of(DialogBody.plainMessage(Component.text( - "ACTIVE QUESTS\n" + listing + "\n\nCREATE A QUEST\n" + "ACTIVE QUESTS\n" + listing + "\n\nYOUR PENDING CLAIMS\n" + claimListing + + "\n\nCREATE A QUEST\n" + "Hold the reward in your main hand. The entire exact stack, including all item metadata, " + "will be removed and held in escrow only if this quest saves successfully." ), 800))) @@ -113,6 +142,25 @@ final class QuestBoardDialogUi implements QuestBoardUi { return QuestListingFormatter.formatAll(browser.activeQuests(now), now); } + String claimListingText(Player player) { + if (claimant == null) { + return "Claim collection is unavailable."; + } + List claims = claimant.pendingClaims(player); + if (claims.isEmpty()) { + return "No pending claims."; + } + return claims.stream().map(claim -> { + String kind = claim.type() == QuestClaimType.DELIVERED_BLOCKS + ? "DELIVERED BLOCKS" : "RETURNED REWARD (" + claim.source().name() + ")"; + String items = claim.items().stream() + .map(item -> item.amount() + " " + item.material()) + .reduce((left, right) -> left + ", " + right) + .orElseThrow(); + return kind + " — " + items + " — quest " + claim.questId(); + }).reduce((left, right) -> left + "\n" + right).orElseThrow(); + } + private ActionButton completionButton(Quest quest) { String id = quest.id().toString(); return ActionButton.builder(Component.text( @@ -169,6 +217,20 @@ final class QuestBoardDialogUi implements QuestBoardUi { } } + void submitClaim(Player player) { + if (claimant == null) { + player.sendMessage("Quest claims are unavailable."); + return; + } + try { + claimant.collect(player); + } catch (IOException | RuntimeException exception) { + player.sendMessage( + "Your claim could not be collected safely. It remains pending; please try again." + ); + } + } + void submitCompletion(Player player, String questId) { if (completer == null) { player.sendMessage("Quest completion is unavailable."); diff --git a/src/main/java/games/dmg/spigotquestboard/QuestClaim.java b/src/main/java/games/dmg/spigotquestboard/QuestClaim.java index 6ca0855..34e877a 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestClaim.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestClaim.java @@ -10,7 +10,8 @@ record QuestClaim( UUID questId, UUID ownerId, List items, - Instant createdAt + Instant createdAt, + QuestClaimSource source ) { QuestClaim { Objects.requireNonNull(id, "id"); @@ -21,5 +22,17 @@ record QuestClaim( throw new IllegalArgumentException("Claim items must not be empty"); } Objects.requireNonNull(createdAt, "createdAt"); + Objects.requireNonNull(source, "source"); + } + + QuestClaim( + UUID id, UUID questId, UUID ownerId, List items, Instant createdAt + ) { + this(id, questId, ownerId, items, createdAt, QuestClaimSource.COMPLETION); + } + + QuestClaimType type() { + return source == QuestClaimSource.COMPLETION + ? QuestClaimType.DELIVERED_BLOCKS : QuestClaimType.RETURNED_REWARD; } } diff --git a/src/main/java/games/dmg/spigotquestboard/QuestClaimController.java b/src/main/java/games/dmg/spigotquestboard/QuestClaimController.java new file mode 100644 index 0000000..ee8320c --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestClaimController.java @@ -0,0 +1,64 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import org.bukkit.entity.Player; + +final class QuestClaimController implements QuestClaimGateway { + private final QuestService quests; + private final QuestClaimInventory inventory; + + QuestClaimController(QuestService quests, QuestClaimInventory inventory) { + this.quests = Objects.requireNonNull(quests, "quests"); + this.inventory = Objects.requireNonNull(inventory, "inventory"); + } + + @Override + public List pendingClaims(Player player) { + Objects.requireNonNull(player, "player"); + return quests.claimsFor(player.getUniqueId()); + } + + @Override + public synchronized ClaimCollectionResult collect(Player player) throws IOException { + Objects.requireNonNull(player, "player"); + List claims = quests.claimsFor(player.getUniqueId()); + if (claims.isEmpty()) { + player.sendMessage("You have no pending quest claims."); + return new ClaimCollectionResult(0, 0); + } + + int collected = 0; + int overflow = 0; + for (QuestClaim claim : claims) { + QuestClaimInventory.PreparedClaim prepared = inventory.prepare(claim.items()); + QuestClaimInventory.Delivery delivery = prepared.deliver(player); + try { + quests.acknowledgeClaim(player.getUniqueId(), claim.id()); + } catch (IOException | RuntimeException exception) { + delivery.rollback(); + throw exception; + } + collected++; + overflow += delivery.overflowAmount(); + player.sendMessage(description(claim)); + } + player.sendMessage("Collected " + collected + " quest claim(s)."); + if (overflow > 0) { + player.sendMessage( + overflow + " item(s) did not fit and were dropped at your feet, protected for you." + ); + } + return new ClaimCollectionResult(collected, overflow); + } + + private static String description(QuestClaim claim) { + if (claim.type() == QuestClaimType.DELIVERED_BLOCKS) { + return "Collected delivered blocks from completed quest " + claim.questId() + "."; + } + String reason = claim.source() == QuestClaimSource.EXPIRATION + ? "expired" : "cancelled"; + return "Collected returned reward from " + reason + " quest " + claim.questId() + "."; + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestClaimGateway.java b/src/main/java/games/dmg/spigotquestboard/QuestClaimGateway.java new file mode 100644 index 0000000..a1835f4 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestClaimGateway.java @@ -0,0 +1,13 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.util.List; +import org.bukkit.entity.Player; + +interface QuestClaimGateway { + ClaimCollectionResult collect(Player player) throws IOException; + + default List pendingClaims(Player player) { + return List.of(); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestClaimInventory.java b/src/main/java/games/dmg/spigotquestboard/QuestClaimInventory.java new file mode 100644 index 0000000..64a732e --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestClaimInventory.java @@ -0,0 +1,17 @@ +package games.dmg.spigotquestboard; + +import java.util.List; +import org.bukkit.entity.Player; + +interface QuestClaimInventory { + PreparedClaim prepare(List items); + + interface PreparedClaim { + Delivery deliver(Player player); + } + + interface Delivery { + int overflowAmount(); + void rollback(); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestClaimSource.java b/src/main/java/games/dmg/spigotquestboard/QuestClaimSource.java new file mode 100644 index 0000000..54d8bf9 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestClaimSource.java @@ -0,0 +1,7 @@ +package games.dmg.spigotquestboard; + +enum QuestClaimSource { + COMPLETION, + CANCELLATION, + EXPIRATION +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestClaimType.java b/src/main/java/games/dmg/spigotquestboard/QuestClaimType.java new file mode 100644 index 0000000..e464a87 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestClaimType.java @@ -0,0 +1,6 @@ +package games.dmg.spigotquestboard; + +enum QuestClaimType { + DELIVERED_BLOCKS, + RETURNED_REWARD +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java index 726ece1..c8c5310 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java @@ -18,10 +18,11 @@ final class QuestCommand implements CommandExecutor, TabCompleter { private final QuestBrowser browser; private final QuestCompletionGateway completer; private final QuestCancellationGateway canceller; + private final QuestClaimGateway claimant; private final Clock clock; QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { - this(creator, browser, null, null, clock); + this(creator, browser, null, null, null, clock); } QuestCommand( @@ -30,7 +31,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter { QuestCompletionGateway completer, Clock clock ) { - this(creator, browser, completer, null, clock); + this(creator, browser, completer, null, null, clock); } QuestCommand( @@ -39,11 +40,23 @@ final class QuestCommand implements CommandExecutor, TabCompleter { QuestCompletionGateway completer, QuestCancellationGateway canceller, Clock clock + ) { + this(creator, browser, completer, canceller, null, clock); + } + + QuestCommand( + QuestCreationGateway creator, + QuestBrowser browser, + QuestCompletionGateway completer, + QuestCancellationGateway canceller, + QuestClaimGateway claimant, + Clock clock ) { this.creator = Objects.requireNonNull(creator, "creator"); this.browser = Objects.requireNonNull(browser, "browser"); this.completer = completer; this.canceller = canceller; + this.claimant = claimant; this.clock = Objects.requireNonNull(clock, "clock"); } @@ -57,6 +70,24 @@ final class QuestCommand implements CommandExecutor, TabCompleter { sender.sendMessage(QuestListingFormatter.formatAll(browser.activeQuests(now), now)); return true; } + if (arguments.length == 1 && "claim".equalsIgnoreCase(arguments[0])) { + if (!(sender instanceof Player player)) { + sender.sendMessage("Only players can collect quest claims."); + return true; + } + if (claimant == null) { + sender.sendMessage("Quest claims are unavailable."); + return true; + } + try { + claimant.collect(player); + } catch (IOException | RuntimeException exception) { + sender.sendMessage( + "Your claim could not be collected safely. It remains pending; please try again." + ); + } + 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."); @@ -131,7 +162,9 @@ final class QuestCommand implements CommandExecutor, TabCompleter { return List.of(); } if (arguments.length == 1) { - return startsWith(List.of("create", "list", "complete", "cancel"), arguments[0]); + return startsWith( + List.of("create", "list", "complete", "cancel", "claim"), arguments[0] + ); } if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) { return creator.suggestBlockMaterials(arguments[1]); @@ -161,7 +194,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter { private static void usage(CommandSender sender) { sender.sendMessage( "Usage: /quests [list] | /quests create | " - + "/quests complete | /quests cancel " + + "/quests complete | /quests cancel | /quests claim" ); 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/QuestExpiryTask.java b/src/main/java/games/dmg/spigotquestboard/QuestExpiryTask.java new file mode 100644 index 0000000..1f52720 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestExpiryTask.java @@ -0,0 +1,39 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.time.Clock; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +final class QuestExpiryTask implements Runnable { + private final QuestService quests; + private final IssuerNotifier notifier; + private final Clock clock; + private final Logger logger; + + QuestExpiryTask( + QuestService quests, IssuerNotifier notifier, Clock clock, Logger logger + ) { + this.quests = quests; + this.notifier = notifier; + this.clock = clock; + this.logger = logger; + } + + @Override + public void run() { + final List claims; + try { + claims = quests.expire(clock.instant()); + } catch (IOException exception) { + logger.log(Level.WARNING, "Could not persist expired quests; expiration will be retried", exception); + return; + } + Set owners = new LinkedHashSet<>(); + claims.forEach(claim -> owners.add(claim.ownerId())); + owners.forEach(notifier::notifyIfOnline); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestService.java b/src/main/java/games/dmg/spigotquestboard/QuestService.java index 45961db..08d205c 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestService.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestService.java @@ -84,7 +84,8 @@ final class QuestService implements QuestBrowser { QuestStatus.COMPLETED ); QuestClaim claim = new QuestClaim( - UUID.randomUUID(), active.id(), active.issuerId(), delivery, completedAt + UUID.randomUUID(), active.id(), active.issuerId(), delivery, completedAt, + QuestClaimSource.COMPLETION ); IssuerNotification notification = new IssuerNotification( UUID.randomUUID(), active.id(), active.issuerId(), @@ -129,7 +130,8 @@ final class QuestService implements QuestBrowser { QuestStatus.CANCELLED ); QuestClaim claim = new QuestClaim( - UUID.randomUUID(), active.id(), active.issuerId(), active.reward(), cancelledAt + UUID.randomUUID(), active.id(), active.issuerId(), active.reward(), cancelledAt, + QuestClaimSource.CANCELLATION ); Map quests = new LinkedHashMap<>(state.quests()); quests.put(active.id(), cancelled); @@ -143,6 +145,66 @@ final class QuestService implements QuestBrowser { return claim; } + synchronized List expire(Instant now) throws IOException { + Objects.requireNonNull(now, "now"); + Map quests = new LinkedHashMap<>(state.quests()); + Map> claims = mutableClaims(state.claims()); + Map notifications = new LinkedHashMap<>(state.notifications()); + List expiredClaims = new ArrayList<>(); + + for (Quest active : state.quests().values()) { + if (active.status() != QuestStatus.ACTIVE || active.expiresAt().isAfter(now)) { + continue; + } + Quest expired = new Quest( + active.id(), active.issuerId(), active.issuerName(), active.requestedMaterial(), + active.requestedAmount(), active.reward(), active.createdAt(), active.expiresAt(), + QuestStatus.EXPIRED + ); + QuestClaim claim = new QuestClaim( + UUID.randomUUID(), active.id(), active.issuerId(), active.reward(), active.expiresAt(), + QuestClaimSource.EXPIRATION + ); + IssuerNotification notification = new IssuerNotification( + UUID.randomUUID(), active.id(), active.issuerId(), + "Quest " + active.id() + " expired. Your returned reward can be claimed " + + "at any quest board or with /quests claim.", + active.expiresAt() + ); + quests.put(active.id(), expired); + claims.computeIfAbsent(active.issuerId(), ignored -> new ArrayList<>()).add(claim); + notifications.put(notification.id(), notification); + expiredClaims.add(claim); + } + if (!expiredClaims.isEmpty()) { + save(new QuestState(quests, claims, notifications)); + } + return List.copyOf(expiredClaims); + } + + synchronized List claimsFor(UUID ownerId) { + Objects.requireNonNull(ownerId, "ownerId"); + return state.claims().getOrDefault(ownerId, List.of()); + } + + synchronized void acknowledgeClaim(UUID ownerId, UUID claimId) throws IOException { + Objects.requireNonNull(ownerId, "ownerId"); + Objects.requireNonNull(claimId, "claimId"); + List existing = state.claims().getOrDefault(ownerId, List.of()); + if (existing.stream().noneMatch(claim -> claim.id().equals(claimId))) { + return; + } + Map> claims = mutableClaims(state.claims()); + List remaining = new ArrayList<>(claims.get(ownerId)); + remaining.removeIf(claim -> claim.id().equals(claimId)); + if (remaining.isEmpty()) { + claims.remove(ownerId); + } else { + claims.put(ownerId, remaining); + } + save(new QuestState(state.quests(), claims, state.notifications())); + } + synchronized List pendingNotifications(UUID recipientId) { Objects.requireNonNull(recipientId, "recipientId"); return state.notifications().values().stream() @@ -172,6 +234,14 @@ final class QuestService implements QuestBrowser { return state; } + private static Map> mutableClaims( + Map> source + ) { + Map> copy = new LinkedHashMap<>(); + source.forEach((owner, claims) -> copy.put(owner, new ArrayList<>(claims))); + return copy; + } + private void save(QuestState candidate) throws IOException { repository.save(candidate); state = candidate; diff --git a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java index 4075231..b02466d 100644 --- a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java +++ b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java @@ -39,18 +39,29 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin { quests, new BukkitQuestCompletionInventory(), notifier ); QuestCancellationGateway canceller = new QuestCancellationController(quests); + QuestClaimGateway claimant = new QuestClaimController( + quests, new BukkitQuestClaimInventory() + ); QuestCommand questCommand = new QuestCommand( - creator, quests, completer, canceller, clock + creator, quests, completer, canceller, claimant, clock ); command("quests").setExecutor(questCommand); command("quests").setTabCompleter(questCommand); getServer().getPluginManager().registerEvents(notifier, this); getServer().getPluginManager().registerEvents( new QuestBoardInteractionListener( - boards, new QuestBoardDialogUi(creator, quests, completer, canceller, clock) + boards, new QuestBoardDialogUi( + creator, quests, completer, canceller, claimant, clock + ) ), this ); + getServer().getScheduler().runTaskTimer( + this, + new QuestExpiryTask(quests, notifier, clock, getLogger()), + 0L, + 20L + ); getLogger().info( "Spigot Quest Board enabled with " + boards.size() + " boards and " + quests.state().quests().size() + " quests." diff --git a/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java b/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java index 6f5a8a8..c4a0363 100644 --- a/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java +++ b/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java @@ -60,7 +60,10 @@ final class YamlQuestRepository implements QuestRepository { UUID.fromString(requiredString(entry, "quest-id")), UUID.fromString(requiredString(entry, "owner-id")), readItems(entry.get("items"), "claim items"), - Instant.parse(requiredString(entry, "created-at")) + Instant.parse(requiredString(entry, "created-at")), + readClaimSource(entry.get("source"), quests.get( + UUID.fromString(requiredString(entry, "quest-id")) + )) ); claims.computeIfAbsent(claim.ownerId(), ignored -> new ArrayList<>()).add(claim); } @@ -111,6 +114,7 @@ final class YamlQuestRepository implements QuestRepository { serialized.put("quest-id", claim.questId().toString()); serialized.put("owner-id", claim.ownerId().toString()); serialized.put("created-at", claim.createdAt().toString()); + serialized.put("source", claim.source().name()); serialized.put("items", writeItems(claim.items())); serializedClaims.add(serialized); } @@ -182,6 +186,20 @@ final class YamlQuestRepository implements QuestRepository { return serializedItems; } + private static QuestClaimSource readClaimSource(Object value, Quest quest) { + if (value instanceof String source) { + return QuestClaimSource.valueOf(source); + } + if (value != null) { + throw new IllegalArgumentException("Invalid claim source"); + } + if (quest == null || quest.status() == QuestStatus.COMPLETED) { + return QuestClaimSource.COMPLETION; + } + return quest.status() == QuestStatus.EXPIRED + ? QuestClaimSource.EXPIRATION : QuestClaimSource.CANCELLATION; + } + private static QuestStatus readStatus(Object value) { if (value == null) { return QuestStatus.ACTIVE; diff --git a/src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java b/src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java index 63b8454..1227e76 100644 --- a/src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java +++ b/src/test/java/games/dmg/spigotquestboard/BukkitIssuerNotifierTest.java @@ -37,6 +37,29 @@ final class BukkitIssuerNotifierTest { assertTrue(repository.state.notifications().isEmpty()); } + @Test + void failedNotificationAcknowledgementRemainsPendingForRetry() throws Exception { + Repository repository = new Repository(); + QuestService service = completedService(repository); + IssuerNotification notification = service.state().notifications().values().iterator().next(); + Server server = mock(Server.class); + Player player = mock(Player.class); + when(server.getPlayer(notification.recipientId())).thenReturn(player); + when(player.isOnline()).thenReturn(true); + when(player.getUniqueId()).thenReturn(notification.recipientId()); + BukkitIssuerNotifier notifier = new BukkitIssuerNotifier( + service, server, Logger.getAnonymousLogger() + ); + repository.fail = true; + + notifier.notifyIfOnline(notification.recipientId()); + assertEquals(1, service.pendingNotifications(notification.recipientId()).size()); + + repository.fail = false; + notifier.notifyIfOnline(notification.recipientId()); + assertTrue(service.pendingNotifications(notification.recipientId()).isEmpty()); + } + @Test void offlineNotificationRemainsDurableUntilNextLogin() throws Exception { Repository repository = new Repository(); @@ -73,7 +96,14 @@ final class BukkitIssuerNotifierTest { 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) { state = candidate; } + @Override + public void save(QuestState candidate) throws java.io.IOException { + if (fail) { + throw new java.io.IOException("disk full"); + } + state = candidate; + } } } diff --git a/src/test/java/games/dmg/spigotquestboard/BukkitQuestClaimInventoryTest.java b/src/test/java/games/dmg/spigotquestboard/BukkitQuestClaimInventoryTest.java new file mode 100644 index 0000000..e88c926 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/BukkitQuestClaimInventoryTest.java @@ -0,0 +1,94 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.Test; + +final class BukkitQuestClaimInventoryTest { + @Test + void inventoryOverflowIsDroppedAtFeetWithOwnerProtection() { + UUID owner = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + World world = mock(World.class); + Location feet = mock(Location.class); + Item dropped = mock(Item.class); + ItemStack requested = mock(ItemStack.class); + ItemStack requestedCopy = mock(ItemStack.class); + ItemStack leftover = mock(ItemStack.class); + ItemStack leftoverCopy = mock(ItemStack.class); + when(requested.clone()).thenReturn(requestedCopy); + when(leftover.clone()).thenReturn(leftoverCopy); + when(leftover.getAmount()).thenReturn(2); + when(player.getUniqueId()).thenReturn(owner); + when(player.getInventory()).thenReturn(inventory); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(feet); + when(inventory.getStorageContents()).thenReturn(new ItemStack[36]); + HashMap leftovers = new HashMap<>(); + leftovers.put(0, leftover); + when(inventory.addItem(any(ItemStack.class))).thenReturn(leftovers); + when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(dropped); + + QuestClaimInventory.Delivery delivery = BukkitQuestClaimInventory.deliver( + player, List.of(requested) + ); + + assertEquals(2, delivery.overflowAmount()); + verify(world).dropItem(feet, leftoverCopy); + verify(dropped).setOwner(owner); + } + + @Test + void ownerProtectionFailureRestoresInventoryAndRemovesPartialDrop() { + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + World world = mock(World.class); + Location feet = mock(Location.class); + Item dropped = mock(Item.class); + ItemStack requested = mock(ItemStack.class); + ItemStack requestedCopy = mock(ItemStack.class); + ItemStack leftover = mock(ItemStack.class); + ItemStack leftoverCopy = mock(ItemStack.class); + ItemStack existing = mock(ItemStack.class); + ItemStack existingCopy = mock(ItemStack.class); + when(requested.clone()).thenReturn(requestedCopy); + when(leftover.clone()).thenReturn(leftoverCopy); + when(existing.clone()).thenReturn(existingCopy); + when(existingCopy.clone()).thenReturn(existingCopy); + ItemStack[] original = new ItemStack[] {existing}; + when(player.getInventory()).thenReturn(inventory); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(feet); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(inventory.getStorageContents()).thenReturn(original); + HashMap leftovers = new HashMap<>(); + leftovers.put(0, leftover); + when(inventory.addItem(requestedCopy)).thenReturn(leftovers); + when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(dropped); + doThrow(new IllegalStateException("owner rejected")).when(dropped).setOwner(any()); + + assertThrows( + IllegalStateException.class, + () -> BukkitQuestClaimInventory.deliver(player, List.of(requested)) + ); + + verify(inventory).setStorageContents(any(ItemStack[].class)); + verify(dropped).remove(); + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java index 03c522c..a28b2b8 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java @@ -52,6 +52,26 @@ final class QuestBoardDialogUiTest { assertEquals(NOW, completer.completedAt); } + @Test + void claimActionUsesEquivalentCollectionFlowAtABoard() { + RecordingCreator creator = new RecordingCreator(false); + java.util.concurrent.atomic.AtomicReference claimantPlayer = + new java.util.concurrent.atomic.AtomicReference<>(); + QuestClaimGateway claimant = player -> { + claimantPlayer.set(player); + return new ClaimCollectionResult(1, 0); + }; + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(), null, null, claimant, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + Player player = mock(Player.class); + + ui.submitClaim(player); + + assertEquals(player, claimantPlayer.get()); + } + @Test void cancellationActionUsesEquivalentCancellationFlow() { RecordingCreator creator = new RecordingCreator(false); @@ -69,6 +89,47 @@ final class QuestBoardDialogUiTest { assertEquals(NOW, canceller.cancelledAt); } + @Test + void boardListingDistinguishesDeliveredBlocksFromReturnedRewards() { + RecordingCreator creator = new RecordingCreator(false); + Player player = mock(Player.class); + UUID owner = UUID.randomUUID(); + org.mockito.Mockito.when(player.getUniqueId()).thenReturn(owner); + QuestClaimGateway claimant = new QuestClaimGateway() { + @Override + public ClaimCollectionResult collect(Player ignored) { + return new ClaimCollectionResult(0, 0); + } + + @Override + public List pendingClaims(Player ignored) { + return List.of( + new QuestClaim( + UUID.randomUUID(), UUID.randomUUID(), owner, + List.of(new EscrowItem("STONE", 2, null)), NOW, + QuestClaimSource.COMPLETION + ), + new QuestClaim( + UUID.randomUUID(), UUID.randomUUID(), owner, + List.of(new EscrowItem("DIAMOND", 1, null)), NOW, + QuestClaimSource.EXPIRATION + ) + ); + } + }; + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(), null, null, claimant, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + String listing = ui.claimListingText(player); + + org.junit.jupiter.api.Assertions.assertTrue(listing.contains("DELIVERED BLOCKS — 2 STONE")); + org.junit.jupiter.api.Assertions.assertTrue( + listing.contains("RETURNED REWARD (EXPIRATION) — 1 DIAMOND") + ); + } + @Test void boardOnlyOffersCancellationActionsForPlayersOwnedActiveQuests() { RecordingCreator creator = new RecordingCreator(false); diff --git a/src/test/java/games/dmg/spigotquestboard/QuestClaimControllerTest.java b/src/test/java/games/dmg/spigotquestboard/QuestClaimControllerTest.java new file mode 100644 index 0000000..6c4c745 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestClaimControllerTest.java @@ -0,0 +1,133 @@ +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 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.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 QuestClaimControllerTest { + @Test + void collectsEveryClaimAndClearlyDistinguishesItemsAndOverflow() throws Exception { + Repository repository = new Repository(); + QuestService service = serviceWithAllClaimSources(repository); + UUID issuer = service.state().claims().keySet().iterator().next(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(issuer); + RecordingInventory inventory = new RecordingInventory(2); + + ClaimCollectionResult result = new QuestClaimController(service, inventory).collect(player); + + assertEquals(new ClaimCollectionResult(3, 6), result); + assertTrue(service.claimsFor(issuer).isEmpty()); + verify(player).sendMessage(contains("delivered blocks from completed quest")); + verify(player).sendMessage(contains("returned reward from cancelled quest")); + verify(player).sendMessage(contains("returned reward from expired quest")); + verify(player).sendMessage(contains("dropped at your feet, protected for you")); + } + + @Test + void deliveryFailureLeavesClaimPendingForRetry() throws Exception { + Repository repository = new Repository(); + QuestService service = expiredService(repository); + UUID issuer = service.state().claims().keySet().iterator().next(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(issuer); + QuestClaimInventory inventory = items -> ignored -> { + throw new IllegalStateException("world rejected drop"); + }; + + assertThrows( + IllegalStateException.class, + () -> new QuestClaimController(service, inventory).collect(player) + ); + + assertEquals(1, service.claimsFor(issuer).size()); + } + + @Test + void acknowledgementFailureRollsBackDeliveryAndLeavesClaimPending() throws Exception { + Repository repository = new Repository(); + QuestService service = expiredService(repository); + UUID issuer = service.state().claims().keySet().iterator().next(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(issuer); + RecordingInventory inventory = new RecordingInventory(0); + repository.fail = true; + + assertThrows( + IOException.class, + () -> new QuestClaimController(service, inventory).collect(player) + ); + + assertEquals(1, inventory.rollbacks); + assertEquals(1, service.claimsFor(issuer).size()); + } + + private static QuestService serviceWithAllClaimSources(Repository repository) throws Exception { + QuestService service = new QuestService(repository); + UUID issuer = UUID.randomUUID(); + Quest completed = create(service, issuer, "STONE"); + Quest cancelled = create(service, issuer, "DIRT"); + Quest expired = create(service, issuer, "SAND"); + service.complete( + completed.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1) + ); + service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1)); + service.expire(expired.expiresAt()); + return service; + } + + private static QuestService expiredService(Repository repository) throws Exception { + QuestService service = new QuestService(repository); + Quest quest = create(service, UUID.randomUUID(), "STONE"); + service.expire(quest.expiresAt()); + return service; + } + + private static Quest create(QuestService service, UUID issuer, String material) + throws IOException { + return service.create( + issuer, "Issuer", material, 1, + List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH + ); + } + + private static final class RecordingInventory implements QuestClaimInventory { + private final int overflow; + private int rollbacks; + + private RecordingInventory(int overflow) { + this.overflow = overflow; + } + + @Override + public PreparedClaim prepare(List items) { + return player -> new Delivery() { + @Override public int overflowAmount() { return overflow; } + @Override public void rollback() { rollbacks++; } + }; + } + } + + 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/QuestCommandTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java index d654f4d..f295252 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java @@ -105,6 +105,28 @@ final class QuestCommandTest { assertEquals(NOW, completer.completedAt); } + @Test + void claimCommandUsesTheSameCollectionGateway() { + RecordingCreator creator = new RecordingCreator(); + Player player = mock(Player.class); + java.util.concurrent.atomic.AtomicReference claimantPlayer = + new java.util.concurrent.atomic.AtomicReference<>(); + QuestClaimGateway claimant = claimingPlayer -> { + claimantPlayer.set(claimingPlayer); + return new ClaimCollectionResult(1, 0); + }; + QuestCommand executor = new QuestCommand( + creator, now -> List.of(), null, null, claimant, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + assertTrue(executor.onCommand( + player, mock(Command.class), "quests", new String[] {"claim"} + )); + + assertEquals(player, claimantPlayer.get()); + } + @Test void cancelCommandRoutesPlayerAndCurrentTimeToEquivalentGateway() { RecordingCreator creator = new RecordingCreator(); @@ -160,6 +182,8 @@ final class QuestCommandTest { executor.onTabComplete(player, command, "quests", new String[] {"li"})); assertEquals(List.of("cancel"), executor.onTabComplete(player, command, "quests", new String[] {"ca"})); + assertEquals(List.of("claim"), + executor.onTabComplete(player, command, "quests", new String[] {"cl"})); assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete( player, command, "quests", new String[] {"create", "sto"} )); diff --git a/src/test/java/games/dmg/spigotquestboard/QuestExpiryAndClaimServiceTest.java b/src/test/java/games/dmg/spigotquestboard/QuestExpiryAndClaimServiceTest.java new file mode 100644 index 0000000..b7427c7 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestExpiryAndClaimServiceTest.java @@ -0,0 +1,127 @@ +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.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class QuestExpiryAndClaimServiceTest { + @Test + void expiryReturnsExactRewardAsTypedClaimAndAcknowledgementRemovesIt() throws Exception { + MemoryQuestRepository repository = new MemoryQuestRepository(); + QuestService service = new QuestService(repository); + UUID issuer = UUID.randomUUID(); + EscrowItem reward = new EscrowItem("DIAMOND", 3, "exact-data"); + Quest quest = service.create( + issuer, "Issuer", "STONE", 64, List.of(reward), Instant.EPOCH + ); + + service.expire(quest.expiresAt()); + List claims = service.claimsFor(issuer); + + assertEquals(QuestStatus.EXPIRED, service.state().quests().get(quest.id()).status()); + assertEquals(List.of(reward), claims.getFirst().items()); + assertEquals(QuestClaimSource.EXPIRATION, claims.getFirst().source()); + assertEquals(QuestClaimType.RETURNED_REWARD, claims.getFirst().type()); + service.acknowledgeClaim(issuer, claims.getFirst().id()); + assertTrue(service.claimsFor(issuer).isEmpty()); + } + + @Test + void sevenDayBoundaryIsExclusiveBeforeAndDueExactlyAtExpiration() throws Exception { + QuestService service = new QuestService(new MemoryQuestRepository()); + Quest quest = create(service, UUID.randomUUID()); + + assertTrue(service.expire(quest.expiresAt().minusNanos(1)).isEmpty()); + assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status()); + + assertEquals(1, service.expire(quest.expiresAt()).size()); + assertEquals(QuestStatus.EXPIRED, service.state().quests().get(quest.id()).status()); + assertTrue(service.expire(quest.expiresAt().plusSeconds(1)).isEmpty()); + assertEquals(1, service.claimsFor(quest.issuerId()).size()); + } + + @Test + void completionCancellationAndExpirationClaimsKeepTheirDistinctSources() throws Exception { + QuestService service = new QuestService(new MemoryQuestRepository()); + UUID issuer = UUID.randomUUID(); + Quest completed = create(service, issuer); + Quest cancelled = service.create( + issuer, "Issuer", "DIRT", 1, + List.of(new EscrowItem("EMERALD", 2, null)), Instant.EPOCH + ); + Quest expired = service.create( + issuer, "Issuer", "SAND", 1, + List.of(new EscrowItem("GOLD_INGOT", 4, null)), Instant.EPOCH + ); + + service.complete( + completed.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1) + ); + service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1)); + service.expire(expired.expiresAt()); + + assertEquals( + List.of( + QuestClaimSource.COMPLETION, + QuestClaimSource.CANCELLATION, + QuestClaimSource.EXPIRATION + ), + service.claimsFor(issuer).stream().map(QuestClaim::source).toList() + ); + assertEquals( + List.of( + QuestClaimType.DELIVERED_BLOCKS, + QuestClaimType.RETURNED_REWARD, + QuestClaimType.RETURNED_REWARD + ), + service.claimsFor(issuer).stream().map(QuestClaim::type).toList() + ); + } + + @Test + void failedExpirationPersistsNothingAndCanBeRetriedWithoutDuplication() throws Exception { + MemoryQuestRepository repository = new MemoryQuestRepository(); + QuestService service = new QuestService(repository); + Quest quest = create(service, UUID.randomUUID()); + repository.fail = true; + + assertThrows(IOException.class, () -> service.expire(quest.expiresAt())); + assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status()); + assertTrue(service.claimsFor(quest.issuerId()).isEmpty()); + assertTrue(service.pendingNotifications(quest.issuerId()).isEmpty()); + + repository.fail = false; + service.expire(quest.expiresAt()); + service.expire(quest.expiresAt()); + assertEquals(1, service.claimsFor(quest.issuerId()).size()); + assertEquals(1, service.pendingNotifications(quest.issuerId()).size()); + } + + private static Quest create(QuestService service, UUID issuer) throws IOException { + return service.create( + issuer, "Issuer", "STONE", 1, + List.of(new EscrowItem("DIAMOND", 3, null)), Instant.EPOCH + ); + } + + private static final class MemoryQuestRepository 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/QuestExpiryTaskTest.java b/src/test/java/games/dmg/spigotquestboard/QuestExpiryTaskTest.java new file mode 100644 index 0000000..50eb12c --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestExpiryTaskTest.java @@ -0,0 +1,44 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; + +final class QuestExpiryTaskTest { + @Test + void scheduledPassExpiresDueQuestAndRequestsImmediateOnlineNotification() throws Exception { + Instant boundary = Instant.parse("2026-09-12T03:00:00Z"); + Repository repository = new Repository(); + QuestService service = new QuestService(repository); + UUID issuer = UUID.randomUUID(); + service.create( + issuer, "Issuer", "STONE", 1, + List.of(new EscrowItem("DIAMOND", 1, null)), + boundary.minusSeconds(604800) + ); + AtomicReference notified = new AtomicReference<>(); + QuestExpiryTask task = new QuestExpiryTask( + service, notified::set, Clock.fixed(boundary, ZoneOffset.UTC), + Logger.getAnonymousLogger() + ); + + task.run(); + + assertEquals(issuer, notified.get()); + assertEquals(1, service.pendingNotifications(issuer).size()); + assertEquals(QuestStatus.EXPIRED, service.state().quests().values().iterator().next().status()); + } + + 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/YamlQuestRepositoryTest.java b/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java index a8d7f52..a8b91f1 100644 --- a/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java +++ b/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java @@ -124,9 +124,43 @@ final class YamlQuestRepositoryTest { assertEquals(expected, repository.load()); String yaml = Files.readString(path); assertTrue(yaml.contains("claims:")); + assertTrue(yaml.contains("source: COMPLETION")); assertTrue(yaml.contains("notifications:")); } + @Test + void sourceLessHistoricalClaimsInferTypeFromQuestLifecycle() throws Exception { + Path path = temporaryDirectory.resolve("quests.yml"); + Files.writeString(path, """ + quests: + - id: 00000000-0000-0000-0000-000000000010 + issuer-id: 00000000-0000-0000-0000-000000000001 + issuer-name: Issuer + requested-material: STONE + requested-amount: 1 + created-at: '2026-09-05T03:00:00Z' + expires-at: '2026-09-12T03:00:00Z' + status: CANCELLED + reward: + - material: DIAMOND + amount: 1 + claims: + - id: 00000000-0000-0000-0000-000000000020 + quest-id: 00000000-0000-0000-0000-000000000010 + owner-id: 00000000-0000-0000-0000-000000000001 + created-at: '2026-09-05T04:00:00Z' + items: + - material: DIAMOND + amount: 1 + """); + + QuestClaim claim = new YamlQuestRepository(path).load().claims().values() + .iterator().next().getFirst(); + + assertEquals(QuestClaimSource.CANCELLATION, claim.source()); + assertEquals(QuestClaimType.RETURNED_REWARD, claim.type()); + } + @Test void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception { Path path = temporaryDirectory.resolve("quests.yml");