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
@@ -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;