feat(quests): add expiration and held item claims
Release / release (push) Successful in 2m22s
CI / build (push) Successful in 1m6s

This commit is contained in:
dmg
2026-09-05 08:15:16 -04:00
parent 5ca939be6c
commit af6d6d0827
25 changed files with 1006 additions and 28 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ The approved behavior is specified in the [OKF knowledge bundle](knowledge/index
## Status ## 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. 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. 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>`. 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 ## Requirements
+8
View File
@@ -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. - 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. - Serialized cancellation against completion and retained active state when persistence fails.
- Verified 58 tests and the plugin JAR with `./gradlew clean check jar`. - 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`.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-007: Expire quests and claim held items" title: "US-007: Expire quests and claim held items"
description: Hold delivered blocks and returned rewards until their owner safely claims them. 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 # 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 ## Acceptance criteria
- [ ] An uncompleted quest expires seven days after its creation time. - [x] 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. - [x] 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. - [x] 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. - [x] Claims from completion, cancellation, and expiration can be collected through any registered board.
- [ ] The board clearly distinguishes delivered blocks from returned rewards. - [x] The board clearly distinguishes delivered blocks from returned rewards.
- [ ] Online issuers receive a real-time message when items become claimable. - [x] Online issuers receive a real-time message when items become claimable.
- [ ] Offline issuers receive a message on their next login. - [x] Offline issuers receive a message on their next login.
- [ ] When player commands are enabled, `/quests claim` collects the same pending claims. - [x] 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. - [x] 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. - [x] 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. - [x] 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] Automated tests verify expiry boundaries, each claim source, notifications, overflow, failure recovery, and persistence.
## Related ## Related
@@ -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<EscrowItem> items) {
List<ItemStack> stacks = items.stream().map(EscrowItem::toItemStack).toList();
return player -> deliver(player, stacks);
}
static Delivery deliver(Player player, List<ItemStack> stacks) {
PlayerInventory inventory = player.getInventory();
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
List<Item> drops = new ArrayList<>();
int overflow = 0;
try {
for (ItemStack stack : stacks) {
Map<Integer, ItemStack> 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;
}
}
@@ -0,0 +1,4 @@
package games.dmg.spigotquestboard;
record ClaimCollectionResult(int claimsCollected, int overflowItems) {
}
@@ -22,10 +22,11 @@ final class QuestBoardDialogUi implements QuestBoardUi {
private final QuestBrowser browser; private final QuestBrowser browser;
private final QuestCompletionGateway completer; private final QuestCompletionGateway completer;
private final QuestCancellationGateway canceller; private final QuestCancellationGateway canceller;
private final QuestClaimGateway claimant;
private final Clock clock; private final Clock clock;
QuestBoardDialogUi(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { QuestBoardDialogUi(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this(creator, browser, null, null, clock); this(creator, browser, null, null, null, clock);
} }
QuestBoardDialogUi( QuestBoardDialogUi(
@@ -34,7 +35,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
QuestCompletionGateway completer, QuestCompletionGateway completer,
Clock clock Clock clock
) { ) {
this(creator, browser, completer, null, clock); this(creator, browser, completer, null, null, clock);
} }
QuestBoardDialogUi( QuestBoardDialogUi(
@@ -43,11 +44,23 @@ final class QuestBoardDialogUi implements QuestBoardUi {
QuestCompletionGateway completer, QuestCompletionGateway completer,
QuestCancellationGateway canceller, QuestCancellationGateway canceller,
Clock clock 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.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser"); this.browser = Objects.requireNonNull(browser, "browser");
this.completer = completer; this.completer = completer;
this.canceller = canceller; this.canceller = canceller;
this.claimant = claimant;
this.clock = Objects.requireNonNull(clock, "clock"); this.clock = Objects.requireNonNull(clock, "clock");
} }
@@ -72,8 +85,23 @@ final class QuestBoardDialogUi implements QuestBoardUi {
java.time.Instant now = clock.instant(); java.time.Instant now = clock.instant();
List<Quest> activeQuests = browser.activeQuests(now); List<Quest> activeQuests = browser.activeQuests(now);
String listing = QuestListingFormatter.formatAll(activeQuests, now); String listing = QuestListingFormatter.formatAll(activeQuests, now);
String claimListing = claimListingText(player);
List<ActionButton> actions = new ArrayList<>(); List<ActionButton> actions = new ArrayList<>();
actions.add(create); 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) { for (Quest quest : activeQuests) {
if (completer != null) { if (completer != null) {
actions.add(completionButton(quest)); actions.add(completionButton(quest));
@@ -85,7 +113,8 @@ final class QuestBoardDialogUi implements QuestBoardUi {
DialogBase base = DialogBase.builder(Component.text("Quest Board")) DialogBase base = DialogBase.builder(Component.text("Quest Board"))
.externalTitle(Component.text("Quest Board — Active quests and create")) .externalTitle(Component.text("Quest Board — Active quests and create"))
.body(List.of(DialogBody.plainMessage(Component.text( .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, " + "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." + "will be removed and held in escrow only if this quest saves successfully."
), 800))) ), 800)))
@@ -113,6 +142,25 @@ final class QuestBoardDialogUi implements QuestBoardUi {
return QuestListingFormatter.formatAll(browser.activeQuests(now), now); return QuestListingFormatter.formatAll(browser.activeQuests(now), now);
} }
String claimListingText(Player player) {
if (claimant == null) {
return "Claim collection is unavailable.";
}
List<QuestClaim> 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) { private ActionButton completionButton(Quest quest) {
String id = quest.id().toString(); String id = quest.id().toString();
return ActionButton.builder(Component.text( 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) { void submitCompletion(Player player, String questId) {
if (completer == null) { if (completer == null) {
player.sendMessage("Quest completion is unavailable."); player.sendMessage("Quest completion is unavailable.");
@@ -10,7 +10,8 @@ record QuestClaim(
UUID questId, UUID questId,
UUID ownerId, UUID ownerId,
List<EscrowItem> items, List<EscrowItem> items,
Instant createdAt Instant createdAt,
QuestClaimSource source
) { ) {
QuestClaim { QuestClaim {
Objects.requireNonNull(id, "id"); Objects.requireNonNull(id, "id");
@@ -21,5 +22,17 @@ record QuestClaim(
throw new IllegalArgumentException("Claim items must not be empty"); throw new IllegalArgumentException("Claim items must not be empty");
} }
Objects.requireNonNull(createdAt, "createdAt"); Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(source, "source");
}
QuestClaim(
UUID id, UUID questId, UUID ownerId, List<EscrowItem> items, Instant createdAt
) {
this(id, questId, ownerId, items, createdAt, QuestClaimSource.COMPLETION);
}
QuestClaimType type() {
return source == QuestClaimSource.COMPLETION
? QuestClaimType.DELIVERED_BLOCKS : QuestClaimType.RETURNED_REWARD;
} }
} }
@@ -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<QuestClaim> 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<QuestClaim> 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() + ".";
}
}
@@ -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<QuestClaim> pendingClaims(Player player) {
return List.of();
}
}
@@ -0,0 +1,17 @@
package games.dmg.spigotquestboard;
import java.util.List;
import org.bukkit.entity.Player;
interface QuestClaimInventory {
PreparedClaim prepare(List<EscrowItem> items);
interface PreparedClaim {
Delivery deliver(Player player);
}
interface Delivery {
int overflowAmount();
void rollback();
}
}
@@ -0,0 +1,7 @@
package games.dmg.spigotquestboard;
enum QuestClaimSource {
COMPLETION,
CANCELLATION,
EXPIRATION
}
@@ -0,0 +1,6 @@
package games.dmg.spigotquestboard;
enum QuestClaimType {
DELIVERED_BLOCKS,
RETURNED_REWARD
}
@@ -18,10 +18,11 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
private final QuestBrowser browser; private final QuestBrowser browser;
private final QuestCompletionGateway completer; private final QuestCompletionGateway completer;
private final QuestCancellationGateway canceller; private final QuestCancellationGateway canceller;
private final QuestClaimGateway claimant;
private final Clock clock; private final Clock clock;
QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this(creator, browser, null, null, clock); this(creator, browser, null, null, null, clock);
} }
QuestCommand( QuestCommand(
@@ -30,7 +31,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestCompletionGateway completer, QuestCompletionGateway completer,
Clock clock Clock clock
) { ) {
this(creator, browser, completer, null, clock); this(creator, browser, completer, null, null, clock);
} }
QuestCommand( QuestCommand(
@@ -39,11 +40,23 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestCompletionGateway completer, QuestCompletionGateway completer,
QuestCancellationGateway canceller, QuestCancellationGateway canceller,
Clock clock 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.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser"); this.browser = Objects.requireNonNull(browser, "browser");
this.completer = completer; this.completer = completer;
this.canceller = canceller; this.canceller = canceller;
this.claimant = claimant;
this.clock = Objects.requireNonNull(clock, "clock"); this.clock = Objects.requireNonNull(clock, "clock");
} }
@@ -57,6 +70,24 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(QuestListingFormatter.formatAll(browser.activeQuests(now), now)); sender.sendMessage(QuestListingFormatter.formatAll(browser.activeQuests(now), now));
return true; 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 (arguments.length == 2 && "complete".equalsIgnoreCase(arguments[0])) {
if (!(sender instanceof Player player)) { if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can complete quests with inventory items."); sender.sendMessage("Only players can complete quests with inventory items.");
@@ -131,7 +162,9 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
return List.of(); return List.of();
} }
if (arguments.length == 1) { 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])) { if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) {
return creator.suggestBlockMaterials(arguments[1]); return creator.suggestBlockMaterials(arguments[1]);
@@ -161,7 +194,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
private static void usage(CommandSender sender) { private static void usage(CommandSender sender) {
sender.sendMessage( sender.sendMessage(
"Usage: /quests [list] | /quests create <block> <quantity> | " "Usage: /quests [list] | /quests create <block> <quantity> | "
+ "/quests complete <quest> | /quests cancel <quest>" + "/quests complete <quest> | /quests cancel <quest> | /quests claim"
); );
sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed."); sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed.");
} }
@@ -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<QuestClaim> 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<java.util.UUID> owners = new LinkedHashSet<>();
claims.forEach(claim -> owners.add(claim.ownerId()));
owners.forEach(notifier::notifyIfOnline);
}
}
@@ -84,7 +84,8 @@ final class QuestService implements QuestBrowser {
QuestStatus.COMPLETED QuestStatus.COMPLETED
); );
QuestClaim claim = new QuestClaim( 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( IssuerNotification notification = new IssuerNotification(
UUID.randomUUID(), active.id(), active.issuerId(), UUID.randomUUID(), active.id(), active.issuerId(),
@@ -129,7 +130,8 @@ final class QuestService implements QuestBrowser {
QuestStatus.CANCELLED QuestStatus.CANCELLED
); );
QuestClaim claim = new QuestClaim( 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<UUID, Quest> quests = new LinkedHashMap<>(state.quests()); Map<UUID, Quest> quests = new LinkedHashMap<>(state.quests());
quests.put(active.id(), cancelled); quests.put(active.id(), cancelled);
@@ -143,6 +145,66 @@ final class QuestService implements QuestBrowser {
return claim; return claim;
} }
synchronized List<QuestClaim> expire(Instant now) throws IOException {
Objects.requireNonNull(now, "now");
Map<UUID, Quest> quests = new LinkedHashMap<>(state.quests());
Map<UUID, List<QuestClaim>> claims = mutableClaims(state.claims());
Map<UUID, IssuerNotification> notifications = new LinkedHashMap<>(state.notifications());
List<QuestClaim> 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<QuestClaim> 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<QuestClaim> existing = state.claims().getOrDefault(ownerId, List.of());
if (existing.stream().noneMatch(claim -> claim.id().equals(claimId))) {
return;
}
Map<UUID, List<QuestClaim>> claims = mutableClaims(state.claims());
List<QuestClaim> 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<IssuerNotification> pendingNotifications(UUID recipientId) { synchronized List<IssuerNotification> pendingNotifications(UUID recipientId) {
Objects.requireNonNull(recipientId, "recipientId"); Objects.requireNonNull(recipientId, "recipientId");
return state.notifications().values().stream() return state.notifications().values().stream()
@@ -172,6 +234,14 @@ final class QuestService implements QuestBrowser {
return state; return state;
} }
private static Map<UUID, List<QuestClaim>> mutableClaims(
Map<UUID, List<QuestClaim>> source
) {
Map<UUID, List<QuestClaim>> copy = new LinkedHashMap<>();
source.forEach((owner, claims) -> copy.put(owner, new ArrayList<>(claims)));
return copy;
}
private void save(QuestState candidate) throws IOException { private void save(QuestState candidate) throws IOException {
repository.save(candidate); repository.save(candidate);
state = candidate; state = candidate;
@@ -39,18 +39,29 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
quests, new BukkitQuestCompletionInventory(), notifier quests, new BukkitQuestCompletionInventory(), notifier
); );
QuestCancellationGateway canceller = new QuestCancellationController(quests); QuestCancellationGateway canceller = new QuestCancellationController(quests);
QuestClaimGateway claimant = new QuestClaimController(
quests, new BukkitQuestClaimInventory()
);
QuestCommand questCommand = new QuestCommand( QuestCommand questCommand = new QuestCommand(
creator, quests, completer, canceller, clock creator, quests, completer, canceller, claimant, clock
); );
command("quests").setExecutor(questCommand); command("quests").setExecutor(questCommand);
command("quests").setTabCompleter(questCommand); command("quests").setTabCompleter(questCommand);
getServer().getPluginManager().registerEvents(notifier, this); getServer().getPluginManager().registerEvents(notifier, this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new QuestBoardInteractionListener( new QuestBoardInteractionListener(
boards, new QuestBoardDialogUi(creator, quests, completer, canceller, clock) boards, new QuestBoardDialogUi(
creator, quests, completer, canceller, claimant, clock
)
), ),
this this
); );
getServer().getScheduler().runTaskTimer(
this,
new QuestExpiryTask(quests, notifier, clock, getLogger()),
0L,
20L
);
getLogger().info( getLogger().info(
"Spigot Quest Board enabled with " + boards.size() + " boards and " "Spigot Quest Board enabled with " + boards.size() + " boards and "
+ quests.state().quests().size() + " quests." + quests.state().quests().size() + " quests."
@@ -60,7 +60,10 @@ final class YamlQuestRepository implements QuestRepository {
UUID.fromString(requiredString(entry, "quest-id")), UUID.fromString(requiredString(entry, "quest-id")),
UUID.fromString(requiredString(entry, "owner-id")), UUID.fromString(requiredString(entry, "owner-id")),
readItems(entry.get("items"), "claim items"), 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); 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("quest-id", claim.questId().toString());
serialized.put("owner-id", claim.ownerId().toString()); serialized.put("owner-id", claim.ownerId().toString());
serialized.put("created-at", claim.createdAt().toString()); serialized.put("created-at", claim.createdAt().toString());
serialized.put("source", claim.source().name());
serialized.put("items", writeItems(claim.items())); serialized.put("items", writeItems(claim.items()));
serializedClaims.add(serialized); serializedClaims.add(serialized);
} }
@@ -182,6 +186,20 @@ final class YamlQuestRepository implements QuestRepository {
return serializedItems; 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) { private static QuestStatus readStatus(Object value) {
if (value == null) { if (value == null) {
return QuestStatus.ACTIVE; return QuestStatus.ACTIVE;
@@ -37,6 +37,29 @@ final class BukkitIssuerNotifierTest {
assertTrue(repository.state.notifications().isEmpty()); 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 @Test
void offlineNotificationRemainsDurableUntilNextLogin() throws Exception { void offlineNotificationRemainsDurableUntilNextLogin() throws Exception {
Repository repository = new Repository(); Repository repository = new Repository();
@@ -73,7 +96,14 @@ final class BukkitIssuerNotifierTest {
private static final class Repository implements QuestRepository { private static final class Repository implements QuestRepository {
private QuestState state = QuestState.empty(); private QuestState state = QuestState.empty();
private boolean fail;
@Override public QuestState load() { return state; } @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;
}
} }
} }
@@ -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<Integer, ItemStack> 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<Integer, ItemStack> 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();
}
}
@@ -52,6 +52,26 @@ final class QuestBoardDialogUiTest {
assertEquals(NOW, completer.completedAt); assertEquals(NOW, completer.completedAt);
} }
@Test
void claimActionUsesEquivalentCollectionFlowAtABoard() {
RecordingCreator creator = new RecordingCreator(false);
java.util.concurrent.atomic.AtomicReference<Player> 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 @Test
void cancellationActionUsesEquivalentCancellationFlow() { void cancellationActionUsesEquivalentCancellationFlow() {
RecordingCreator creator = new RecordingCreator(false); RecordingCreator creator = new RecordingCreator(false);
@@ -69,6 +89,47 @@ final class QuestBoardDialogUiTest {
assertEquals(NOW, canceller.cancelledAt); 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<QuestClaim> 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 @Test
void boardOnlyOffersCancellationActionsForPlayersOwnedActiveQuests() { void boardOnlyOffersCancellationActionsForPlayersOwnedActiveQuests() {
RecordingCreator creator = new RecordingCreator(false); RecordingCreator creator = new RecordingCreator(false);
@@ -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<EscrowItem> 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;
}
}
}
@@ -105,6 +105,28 @@ final class QuestCommandTest {
assertEquals(NOW, completer.completedAt); assertEquals(NOW, completer.completedAt);
} }
@Test
void claimCommandUsesTheSameCollectionGateway() {
RecordingCreator creator = new RecordingCreator();
Player player = mock(Player.class);
java.util.concurrent.atomic.AtomicReference<Player> 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 @Test
void cancelCommandRoutesPlayerAndCurrentTimeToEquivalentGateway() { void cancelCommandRoutesPlayerAndCurrentTimeToEquivalentGateway() {
RecordingCreator creator = new RecordingCreator(); RecordingCreator creator = new RecordingCreator();
@@ -160,6 +182,8 @@ final class QuestCommandTest {
executor.onTabComplete(player, command, "quests", new String[] {"li"})); executor.onTabComplete(player, command, "quests", new String[] {"li"}));
assertEquals(List.of("cancel"), assertEquals(List.of("cancel"),
executor.onTabComplete(player, command, "quests", new String[] {"ca"})); 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( assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete(
player, command, "quests", new String[] {"create", "sto"} player, command, "quests", new String[] {"create", "sto"}
)); ));
@@ -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<QuestClaim> 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;
}
}
}
@@ -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<UUID> 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; }
}
}
@@ -124,9 +124,43 @@ final class YamlQuestRepositoryTest {
assertEquals(expected, repository.load()); assertEquals(expected, repository.load());
String yaml = Files.readString(path); String yaml = Files.readString(path);
assertTrue(yaml.contains("claims:")); assertTrue(yaml.contains("claims:"));
assertTrue(yaml.contains("source: COMPLETION"));
assertTrue(yaml.contains("notifications:")); 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 @Test
void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception { void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml"); Path path = temporaryDirectory.resolve("quests.yml");