feat(quests): add issuer quest cancellation
Release / release (push) Successful in 2m22s
CI / build (push) Successful in 1m8s

This commit is contained in:
dmg
2026-09-05 07:54:23 -04:00
parent 9344390599
commit 5ca939be6c
13 changed files with 491 additions and 19 deletions
+1 -1
View File
@@ -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 <quest>` 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 <quest>` 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 <quest>`; exact rewards are held for later claim collection. Claim collection remains under development.
## Requirements
+7
View File
@@ -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 <quest>` 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`.
@@ -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 <quest>` 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 <quest>` 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
@@ -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<ActionButton> 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<String> 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.");
@@ -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;
}
}
@@ -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;
}
@@ -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 <block> <quantity> | "
+ "/quests complete <quest>"
+ "/quests complete <quest> | /quests cancel <quest>"
);
sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed.");
}
@@ -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<UUID, Quest> quests = new LinkedHashMap<>(state.quests());
quests.put(active.id(), cancelled);
Map<UUID, List<QuestClaim>> claims = new LinkedHashMap<>(state.claims());
List<QuestClaim> 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<IssuerNotification> pendingNotifications(UUID recipientId) {
Objects.requireNonNull(recipientId, "recipientId");
return state.notifications().values().stream()
@@ -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
);
@@ -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;
@@ -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; }
}
}
@@ -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<Future<Boolean>> 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;
}
}
}
@@ -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;