diff --git a/README.md b/README.md index e753c9e..d4b35d4 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. 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 `; exact rewards are held for later claim collection. Claim collection remains under development. ## Requirements diff --git a/knowledge/log.md b/knowledge/log.md index 84db598..64e497c 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -54,3 +54,10 @@ description: Chronological record of material decisions affecting Spigot Quest B - 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`. + +## 2026-09-05 — Issuer quest cancellation + +- Added issuer-only board actions and `/quests cancel ` with ownership-filtered autocomplete. +- 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`. diff --git a/knowledge/user-stories/us-006-cancel-an-owned-quest.md b/knowledge/user-stories/us-006-cancel-an-owned-quest.md index 61a1e4c..ad72868 100644 --- a/knowledge/user-stories/us-006-cancel-an-owned-quest.md +++ b/knowledge/user-stories/us-006-cancel-an-owned-quest.md @@ -2,7 +2,7 @@ type: User Story title: "US-006: Cancel an owned quest" description: Let an issuer cancel an active quest and reclaim its escrowed reward safely. -status: backlog +status: done --- # US-006: Cancel an owned quest @@ -11,15 +11,15 @@ As a **quest issuer**, I want to cancel my active quest so that I can reclaim th ## Acceptance criteria -- [ ] An issuer can cancel their own active quest through any registered board. -- [ ] A player cannot cancel a quest issued by another player. -- [ ] Completed, cancelled, and expired quests cannot be cancelled. -- [ ] Cancellation removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer. -- [ ] The reward is not inserted directly into the issuer's inventory during cancellation. -- [ ] When player commands are enabled, `/quests cancel ` provides equivalent behavior and only autocompletes the player's cancellable quest identifiers. -- [ ] Cancellation and simultaneous completion are serialized so items cannot be duplicated or lost. -- [ ] Persistence failure leaves the quest active and its reward escrowed. -- [ ] Automated tests verify ownership, state validation, claim creation, autocomplete, rollback, and completion races. +- [x] An issuer can cancel their own active quest through any registered board. +- [x] A player cannot cancel a quest issued by another player. +- [x] Completed, cancelled, and expired quests cannot be cancelled. +- [x] Cancellation removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer. +- [x] The reward is not inserted directly into the issuer's inventory during cancellation. +- [x] When player commands are enabled, `/quests cancel ` provides equivalent behavior and only autocompletes the player's cancellable quest identifiers. +- [x] Cancellation and simultaneous completion are serialized so items cannot be duplicated or lost. +- [x] Persistence failure leaves the quest active and its reward escrowed. +- [x] Automated tests verify ownership, state validation, claim creation, autocomplete, rollback, and completion races. ## Related diff --git a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java index 3cddd19..a6456d5 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java @@ -21,10 +21,11 @@ final class QuestBoardDialogUi implements QuestBoardUi { private final QuestCreationGateway creator; private final QuestBrowser browser; private final QuestCompletionGateway completer; + private final QuestCancellationGateway canceller; private final Clock clock; QuestBoardDialogUi(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { - this(creator, browser, null, clock); + this(creator, browser, null, null, clock); } QuestBoardDialogUi( @@ -32,10 +33,21 @@ final class QuestBoardDialogUi implements QuestBoardUi { QuestBrowser browser, QuestCompletionGateway completer, Clock clock + ) { + this(creator, browser, completer, null, clock); + } + + QuestBoardDialogUi( + QuestCreationGateway creator, + QuestBrowser browser, + QuestCompletionGateway completer, + QuestCancellationGateway canceller, + Clock clock ) { this.creator = Objects.requireNonNull(creator, "creator"); this.browser = Objects.requireNonNull(browser, "browser"); this.completer = completer; + this.canceller = canceller; this.clock = Objects.requireNonNull(clock, "clock"); } @@ -62,10 +74,13 @@ final class QuestBoardDialogUi implements QuestBoardUi { String listing = QuestListingFormatter.formatAll(activeQuests, now); List actions = new ArrayList<>(); actions.add(create); - if (completer != null) { - for (Quest quest : activeQuests) { + for (Quest quest : activeQuests) { + if (completer != null) { actions.add(completionButton(quest)); } + if (canceller != null && quest.issuerId().equals(player.getUniqueId())) { + actions.add(cancellationButton(quest)); + } } DialogBase base = DialogBase.builder(Component.text("Quest Board")) .externalTitle(Component.text("Quest Board — Active quests and create")) @@ -116,6 +131,44 @@ final class QuestBoardDialogUi implements QuestBoardUi { .build(); } + private ActionButton cancellationButton(Quest quest) { + String id = quest.id().toString(); + return ActionButton.builder(Component.text( + "Cancel " + quest.requestedAmount() + " " + quest.requestedMaterial() + )) + .tooltip(Component.text("Return the escrowed reward to claims for quest " + id)) + .width(250) + .action(DialogAction.customClick((response, audience) -> { + if (audience instanceof Player respondingPlayer) { + submitCancellation(respondingPlayer, id); + } + }, ClickCallback.Options.builder() + .uses(1) + .lifetime(Duration.ofMinutes(10)) + .build())) + .build(); + } + + List cancellableQuestIds(Player player) { + return browser.cancellableQuestIds(player.getUniqueId(), clock.instant()); + } + + void submitCancellation(Player player, String questId) { + if (canceller == null) { + player.sendMessage("Quest cancellation is unavailable."); + return; + } + try { + canceller.cancel(player, questId, clock.instant()); + } catch (IllegalArgumentException | IllegalStateException exception) { + player.sendMessage(exception.getMessage()); + } catch (IOException exception) { + player.sendMessage( + "The quest could not be saved. It remains active and its reward remains escrowed." + ); + } + } + void submitCompletion(Player player, String questId) { if (completer == null) { player.sendMessage("Quest completion is unavailable."); diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCancellationController.java b/src/main/java/games/dmg/spigotquestboard/QuestCancellationController.java new file mode 100644 index 0000000..2edb710 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestCancellationController.java @@ -0,0 +1,31 @@ +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 QuestCancellationController implements QuestCancellationGateway { + private final QuestService quests; + + QuestCancellationController(QuestService quests) { + this.quests = Objects.requireNonNull(quests, "quests"); + } + + @Override + public QuestClaim cancel(Player player, String questId, Instant cancelledAt) 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); + } + QuestClaim claim = quests.cancel(id, player.getUniqueId(), cancelledAt); + player.sendMessage( + "Quest " + id + " cancelled. Your exact escrowed reward is ready to claim at a quest board." + ); + return claim; + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCancellationGateway.java b/src/main/java/games/dmg/spigotquestboard/QuestCancellationGateway.java new file mode 100644 index 0000000..17743aa --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestCancellationGateway.java @@ -0,0 +1,9 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.time.Instant; +import org.bukkit.entity.Player; + +interface QuestCancellationGateway { + QuestClaim cancel(Player player, String questId, Instant cancelledAt) throws IOException; +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java index 96e2e72..726ece1 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java @@ -17,10 +17,11 @@ final class QuestCommand implements CommandExecutor, TabCompleter { private final QuestCreationGateway creator; private final QuestBrowser browser; private final QuestCompletionGateway completer; + private final QuestCancellationGateway canceller; private final Clock clock; QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { - this(creator, browser, null, clock); + this(creator, browser, null, null, clock); } QuestCommand( @@ -28,10 +29,21 @@ final class QuestCommand implements CommandExecutor, TabCompleter { QuestBrowser browser, QuestCompletionGateway completer, Clock clock + ) { + this(creator, browser, completer, null, clock); + } + + QuestCommand( + QuestCreationGateway creator, + QuestBrowser browser, + QuestCompletionGateway completer, + QuestCancellationGateway canceller, + Clock clock ) { this.creator = Objects.requireNonNull(creator, "creator"); this.browser = Objects.requireNonNull(browser, "browser"); this.completer = completer; + this.canceller = canceller; this.clock = Objects.requireNonNull(clock, "clock"); } @@ -65,6 +77,26 @@ final class QuestCommand implements CommandExecutor, TabCompleter { } return true; } + if (arguments.length == 2 && "cancel".equalsIgnoreCase(arguments[0])) { + if (!(sender instanceof Player player)) { + sender.sendMessage("Only players can cancel quests."); + return true; + } + if (canceller == null) { + sender.sendMessage("Quest cancellation is unavailable."); + return true; + } + try { + canceller.cancel(player, arguments[1], clock.instant()); + } catch (IllegalArgumentException | IllegalStateException exception) { + sender.sendMessage(exception.getMessage()); + } catch (IOException exception) { + sender.sendMessage( + "The quest could not be saved. It remains active and its reward remains escrowed." + ); + } + return true; + } if (arguments.length != 3 || !"create".equalsIgnoreCase(arguments[0])) { usage(sender); return true; @@ -99,7 +131,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter { return List.of(); } if (arguments.length == 1) { - return startsWith(List.of("create", "list", "complete"), arguments[0]); + return startsWith(List.of("create", "list", "complete", "cancel"), arguments[0]); } if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) { return creator.suggestBlockMaterials(arguments[1]); @@ -129,7 +161,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter { private static void usage(CommandSender sender) { sender.sendMessage( "Usage: /quests [list] | /quests create | " - + "/quests complete " + + "/quests complete | /quests cancel " ); 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/QuestService.java b/src/main/java/games/dmg/spigotquestboard/QuestService.java index 3cfc677..45961db 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestService.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestService.java @@ -107,6 +107,42 @@ final class QuestService implements QuestBrowser { return new QuestCompletion(completed, claim, completed.reward()); } + synchronized QuestClaim cancel(UUID questId, UUID issuerId, Instant cancelledAt) + throws IOException { + Objects.requireNonNull(questId, "questId"); + Objects.requireNonNull(issuerId, "issuerId"); + Objects.requireNonNull(cancelledAt, "cancelledAt"); + Quest active = state.quests().get(questId); + if (active == null) { + throw new IllegalArgumentException("Quest not found: " + questId); + } + if (!active.issuerId().equals(issuerId)) { + throw new IllegalArgumentException("Only the quest issuer can cancel this quest."); + } + if (active.status() != QuestStatus.ACTIVE || !cancelledAt.isBefore(active.expiresAt())) { + throw new IllegalStateException("That quest is no longer active or has expired."); + } + + Quest cancelled = new Quest( + active.id(), active.issuerId(), active.issuerName(), active.requestedMaterial(), + active.requestedAmount(), active.reward(), active.createdAt(), active.expiresAt(), + QuestStatus.CANCELLED + ); + QuestClaim claim = new QuestClaim( + UUID.randomUUID(), active.id(), active.issuerId(), active.reward(), cancelledAt + ); + Map quests = new LinkedHashMap<>(state.quests()); + quests.put(active.id(), cancelled); + Map> claims = new LinkedHashMap<>(state.claims()); + List ownerClaims = new ArrayList<>( + claims.getOrDefault(active.issuerId(), List.of()) + ); + ownerClaims.add(claim); + claims.put(active.issuerId(), ownerClaims); + save(new QuestState(quests, claims, state.notifications())); + return claim; + } + synchronized List pendingNotifications(UUID recipientId) { Objects.requireNonNull(recipientId, "recipientId"); return state.notifications().values().stream() diff --git a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java index 86a736d..4075231 100644 --- a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java +++ b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java @@ -38,13 +38,16 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin { QuestCompletionGateway completer = new QuestCompletionController( quests, new BukkitQuestCompletionInventory(), notifier ); - QuestCommand questCommand = new QuestCommand(creator, quests, completer, clock); + QuestCancellationGateway canceller = new QuestCancellationController(quests); + QuestCommand questCommand = new QuestCommand( + creator, quests, completer, canceller, 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, clock) + boards, new QuestBoardDialogUi(creator, quests, completer, canceller, clock) ), this ); diff --git a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java index fdb071b..03c522c 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java @@ -52,6 +52,41 @@ final class QuestBoardDialogUiTest { assertEquals(NOW, completer.completedAt); } + @Test + void cancellationActionUsesEquivalentCancellationFlow() { + RecordingCreator creator = new RecordingCreator(false); + RecordingCanceller canceller = new RecordingCanceller(); + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(), null, canceller, Clock.fixed(NOW, ZoneOffset.UTC) + ); + Player player = mock(Player.class); + String id = UUID.randomUUID().toString(); + + ui.submitCancellation(player, id); + + assertEquals(player, canceller.player); + assertEquals(id, canceller.questId); + assertEquals(NOW, canceller.cancelledAt); + } + + @Test + void boardOnlyOffersCancellationActionsForPlayersOwnedActiveQuests() { + RecordingCreator creator = new RecordingCreator(false); + Quest owned = creator.quest(NOW); + Quest other = new Quest( + UUID.randomUUID(), UUID.randomUUID(), "Other", "DIRT", 1, + List.of(new EscrowItem("COAL", 1, null)), NOW, NOW.plusSeconds(604800) + ); + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(owned, other), null, new RecordingCanceller(), + Clock.fixed(NOW, ZoneOffset.UTC) + ); + Player issuer = mock(Player.class); + org.mockito.Mockito.when(issuer.getUniqueId()).thenReturn(owned.issuerId()); + + assertEquals(List.of(owned.id().toString()), ui.cancellableQuestIds(issuer)); + } + @Test void everyOpeningBuildsAListingFromTheCurrentActiveQuests() { RecordingCreator creator = new RecordingCreator(false); @@ -77,6 +112,20 @@ final class QuestBoardDialogUiTest { ); } + private static final class RecordingCanceller implements QuestCancellationGateway { + private Player player; + private String questId; + private Instant cancelledAt; + + @Override + public QuestClaim cancel(Player cancellingPlayer, String id, Instant instant) { + player = cancellingPlayer; + questId = id; + cancelledAt = instant; + return null; + } + } + private static final class RecordingCompleter implements QuestCompletionGateway { private Player player; private String questId; diff --git a/src/test/java/games/dmg/spigotquestboard/QuestCancellationControllerTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCancellationControllerTest.java new file mode 100644 index 0000000..bbc8321 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestCancellationControllerTest.java @@ -0,0 +1,58 @@ +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.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 QuestCancellationControllerTest { + @Test + void cancelsAsPlayerIdentityAndCreatesClaimWithoutInventorySettlement() throws Exception { + Repository repository = new Repository(); + QuestService service = new QuestService(repository); + UUID issuer = UUID.randomUUID(); + Quest quest = service.create( + issuer, "Issuer", "STONE", 1, + List.of(new EscrowItem("DIAMOND", 2, "exact-data")), Instant.EPOCH + ); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(issuer); + + QuestClaim claim = new QuestCancellationController(service).cancel( + player, quest.id().toString(), Instant.EPOCH.plusSeconds(1) + ); + + assertEquals(quest.reward(), claim.items()); + assertEquals(List.of(claim), service.state().claims().get(issuer)); + verify(player).sendMessage(contains("claim")); + } + + @Test + void rejectsMalformedQuestIdentifier() throws Exception { + QuestCancellationController controller = new QuestCancellationController( + new QuestService(new Repository()) + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> controller.cancel(mock(Player.class), "not-a-uuid", Instant.EPOCH) + ); + + assertEquals("Quest identifier must be a valid UUID.", exception.getMessage()); + } + + private static final class Repository implements QuestRepository { + private QuestState state = QuestState.empty(); + @Override public QuestState load() { return state; } + @Override public void save(QuestState candidate) throws IOException { state = candidate; } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestCancellationServiceTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCancellationServiceTest.java new file mode 100644 index 0000000..555a4e3 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestCancellationServiceTest.java @@ -0,0 +1,159 @@ +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 QuestCancellationServiceTest { + @Test + void onlyIssuerCanCancelAndExactRewardBecomesClaim() throws Exception { + Repository repository = new Repository(); + QuestService service = new QuestService(repository); + UUID issuer = UUID.randomUUID(); + EscrowItem reward = new EscrowItem("DIAMOND", 3, "exact-item-data"); + Quest quest = service.create( + issuer, "Issuer", "STONE", 64, List.of(reward), Instant.EPOCH + ); + + assertThrows(IllegalArgumentException.class, () -> service.cancel( + quest.id(), UUID.randomUUID(), Instant.EPOCH.plusSeconds(1) + )); + QuestClaim claim = service.cancel( + quest.id(), issuer, Instant.EPOCH.plusSeconds(1) + ); + + assertEquals(QuestStatus.CANCELLED, service.state().quests().get(quest.id()).status()); + assertEquals(List.of(reward), claim.items()); + assertEquals(List.of(claim), service.state().claims().get(issuer)); + assertTrue(service.activeQuests(Instant.EPOCH.plusSeconds(1)).isEmpty()); + } + + @Test + void completedCancelledAndExpiredQuestsCannotBeCancelled() throws Exception { + Repository repository = new Repository(); + QuestService service = new QuestService(repository); + UUID issuer = UUID.randomUUID(); + Quest completed = quest(service, issuer); + service.complete( + completed.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1) + ); + Quest cancelled = quest(service, issuer); + service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1)); + Quest expired = quest(service, issuer); + + assertThrows(IllegalStateException.class, () -> service.cancel( + completed.id(), issuer, Instant.EPOCH.plusSeconds(2) + )); + assertThrows(IllegalStateException.class, () -> service.cancel( + cancelled.id(), issuer, Instant.EPOCH.plusSeconds(2) + )); + assertThrows(IllegalStateException.class, () -> service.cancel( + expired.id(), issuer, expired.expiresAt() + )); + } + + @Test + void persistenceFailureLeavesQuestActiveAndRewardEscrowed() throws Exception { + Repository repository = new Repository(); + QuestService service = new QuestService(repository); + UUID issuer = UUID.randomUUID(); + Quest quest = quest(service, issuer); + QuestState before = service.state(); + repository.fail = true; + + assertThrows(IOException.class, () -> service.cancel( + quest.id(), issuer, Instant.EPOCH.plusSeconds(1) + )); + + assertEquals(before, service.state()); + assertEquals(before, repository.state); + assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status()); + assertTrue(service.state().claims().isEmpty()); + } + + @Test + void simultaneousCancellationAndCompletionSettleEscrowExactlyOnce() throws Exception { + Repository repository = new Repository(); + QuestService service = new QuestService(repository); + UUID issuer = UUID.randomUUID(); + Quest quest = quest(service, issuer); + CountDownLatch start = new CountDownLatch(1); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + List> attempts = new ArrayList<>(); + attempts.add(executor.submit(() -> { + start.await(); + try { + service.cancel(quest.id(), issuer, Instant.EPOCH.plusSeconds(1)); + return true; + } catch (IllegalStateException exception) { + return false; + } + })); + 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()); + } + + QuestStatus status = service.state().quests().get(quest.id()).status(); + assertTrue(status == QuestStatus.CANCELLED || status == QuestStatus.COMPLETED); + assertEquals(1, service.state().claims().get(issuer).size()); + QuestClaim settlement = service.state().claims().get(issuer).getFirst(); + if (status == QuestStatus.CANCELLED) { + assertEquals(quest.reward(), settlement.items()); + assertTrue(service.state().notifications().isEmpty()); + } else { + assertEquals(List.of(new EscrowItem("STONE", 1, null)), settlement.items()); + assertEquals(1, service.state().notifications().size()); + } + } + + private static Quest quest(QuestService service, UUID issuer) throws IOException { + return service.create( + issuer, "Issuer", "STONE", 1, + List.of(new EscrowItem("DIAMOND", 2, null)), Instant.EPOCH + ); + } + + 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 29dd1f6..d654f4d 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java @@ -105,6 +105,25 @@ final class QuestCommandTest { assertEquals(NOW, completer.completedAt); } + @Test + void cancelCommandRoutesPlayerAndCurrentTimeToEquivalentGateway() { + RecordingCreator creator = new RecordingCreator(); + RecordingCanceller canceller = new RecordingCanceller(); + QuestCommand executor = new QuestCommand( + creator, now -> List.of(), null, canceller, 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[] {"cancel", id} + )); + + assertEquals(player, canceller.player); + assertEquals(id, canceller.questId); + assertEquals(NOW, canceller.cancelledAt); + } + @Test void questIdentifierAutocompleteOnlyUsesActiveAndOwnedQuests() { RecordingCreator creator = new RecordingCreator(); @@ -139,6 +158,8 @@ final class QuestCommandTest { executor.onTabComplete(player, command, "quests", new String[] {"cr"})); assertEquals(List.of("list"), executor.onTabComplete(player, command, "quests", new String[] {"li"})); + assertEquals(List.of("cancel"), + executor.onTabComplete(player, command, "quests", new String[] {"ca"})); assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete( player, command, "quests", new String[] {"create", "sto"} )); @@ -159,6 +180,20 @@ final class QuestCommandTest { return new QuestCommand(creator, browser, Clock.fixed(NOW, ZoneOffset.UTC)); } + private static final class RecordingCanceller implements QuestCancellationGateway { + private Player player; + private String questId; + private Instant cancelledAt; + + @Override + public QuestClaim cancel(Player cancellingPlayer, String id, Instant instant) { + player = cancellingPlayer; + questId = id; + cancelledAt = instant; + return null; + } + } + private static final class RecordingCompleter implements QuestCompletionGateway { private Player player; private String questId;