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
@@ -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 QuestCompletionGateway completer;
private final QuestCancellationGateway canceller;
private final QuestClaimGateway claimant;
private final Clock clock;
QuestBoardDialogUi(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this(creator, browser, null, null, clock);
this(creator, browser, null, null, null, clock);
}
QuestBoardDialogUi(
@@ -34,7 +35,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
QuestCompletionGateway completer,
Clock clock
) {
this(creator, browser, completer, null, clock);
this(creator, browser, completer, null, null, clock);
}
QuestBoardDialogUi(
@@ -43,11 +44,23 @@ final class QuestBoardDialogUi implements QuestBoardUi {
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
Clock clock
) {
this(creator, browser, completer, canceller, null, clock);
}
QuestBoardDialogUi(
QuestCreationGateway creator,
QuestBrowser browser,
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
QuestClaimGateway claimant,
Clock clock
) {
this.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser");
this.completer = completer;
this.canceller = canceller;
this.claimant = claimant;
this.clock = Objects.requireNonNull(clock, "clock");
}
@@ -72,8 +85,23 @@ final class QuestBoardDialogUi implements QuestBoardUi {
java.time.Instant now = clock.instant();
List<Quest> activeQuests = browser.activeQuests(now);
String listing = QuestListingFormatter.formatAll(activeQuests, now);
String claimListing = claimListingText(player);
List<ActionButton> actions = new ArrayList<>();
actions.add(create);
if (claimant != null) {
actions.add(ActionButton.builder(Component.text("Collect pending claims"))
.tooltip(Component.text("Collect delivered blocks and returned rewards"))
.width(250)
.action(DialogAction.customClick((response, audience) -> {
if (audience instanceof Player respondingPlayer) {
submitClaim(respondingPlayer);
}
}, ClickCallback.Options.builder()
.uses(1)
.lifetime(Duration.ofMinutes(10))
.build()))
.build());
}
for (Quest quest : activeQuests) {
if (completer != null) {
actions.add(completionButton(quest));
@@ -85,7 +113,8 @@ final class QuestBoardDialogUi implements QuestBoardUi {
DialogBase base = DialogBase.builder(Component.text("Quest Board"))
.externalTitle(Component.text("Quest Board — Active quests and create"))
.body(List.of(DialogBody.plainMessage(Component.text(
"ACTIVE QUESTS\n" + listing + "\n\nCREATE A QUEST\n"
"ACTIVE QUESTS\n" + listing + "\n\nYOUR PENDING CLAIMS\n" + claimListing
+ "\n\nCREATE A QUEST\n"
+ "Hold the reward in your main hand. The entire exact stack, including all item metadata, "
+ "will be removed and held in escrow only if this quest saves successfully."
), 800)))
@@ -113,6 +142,25 @@ final class QuestBoardDialogUi implements QuestBoardUi {
return QuestListingFormatter.formatAll(browser.activeQuests(now), now);
}
String claimListingText(Player player) {
if (claimant == null) {
return "Claim collection is unavailable.";
}
List<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) {
String id = quest.id().toString();
return ActionButton.builder(Component.text(
@@ -169,6 +217,20 @@ final class QuestBoardDialogUi implements QuestBoardUi {
}
}
void submitClaim(Player player) {
if (claimant == null) {
player.sendMessage("Quest claims are unavailable.");
return;
}
try {
claimant.collect(player);
} catch (IOException | RuntimeException exception) {
player.sendMessage(
"Your claim could not be collected safely. It remains pending; please try again."
);
}
}
void submitCompletion(Player player, String questId) {
if (completer == null) {
player.sendMessage("Quest completion is unavailable.");
@@ -10,7 +10,8 @@ record QuestClaim(
UUID questId,
UUID ownerId,
List<EscrowItem> items,
Instant createdAt
Instant createdAt,
QuestClaimSource source
) {
QuestClaim {
Objects.requireNonNull(id, "id");
@@ -21,5 +22,17 @@ record QuestClaim(
throw new IllegalArgumentException("Claim items must not be empty");
}
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(source, "source");
}
QuestClaim(
UUID id, UUID questId, UUID ownerId, List<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 QuestCompletionGateway completer;
private final QuestCancellationGateway canceller;
private final QuestClaimGateway claimant;
private final Clock clock;
QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this(creator, browser, null, null, clock);
this(creator, browser, null, null, null, clock);
}
QuestCommand(
@@ -30,7 +31,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestCompletionGateway completer,
Clock clock
) {
this(creator, browser, completer, null, clock);
this(creator, browser, completer, null, null, clock);
}
QuestCommand(
@@ -39,11 +40,23 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
Clock clock
) {
this(creator, browser, completer, canceller, null, clock);
}
QuestCommand(
QuestCreationGateway creator,
QuestBrowser browser,
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
QuestClaimGateway claimant,
Clock clock
) {
this.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser");
this.completer = completer;
this.canceller = canceller;
this.claimant = claimant;
this.clock = Objects.requireNonNull(clock, "clock");
}
@@ -57,6 +70,24 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(QuestListingFormatter.formatAll(browser.activeQuests(now), now));
return true;
}
if (arguments.length == 1 && "claim".equalsIgnoreCase(arguments[0])) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can collect quest claims.");
return true;
}
if (claimant == null) {
sender.sendMessage("Quest claims are unavailable.");
return true;
}
try {
claimant.collect(player);
} catch (IOException | RuntimeException exception) {
sender.sendMessage(
"Your claim could not be collected safely. It remains pending; please try again."
);
}
return true;
}
if (arguments.length == 2 && "complete".equalsIgnoreCase(arguments[0])) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can complete quests with inventory items.");
@@ -131,7 +162,9 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
return List.of();
}
if (arguments.length == 1) {
return startsWith(List.of("create", "list", "complete", "cancel"), arguments[0]);
return startsWith(
List.of("create", "list", "complete", "cancel", "claim"), arguments[0]
);
}
if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) {
return creator.suggestBlockMaterials(arguments[1]);
@@ -161,7 +194,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
private static void usage(CommandSender sender) {
sender.sendMessage(
"Usage: /quests [list] | /quests create <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.");
}
@@ -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
);
QuestClaim claim = new QuestClaim(
UUID.randomUUID(), active.id(), active.issuerId(), delivery, completedAt
UUID.randomUUID(), active.id(), active.issuerId(), delivery, completedAt,
QuestClaimSource.COMPLETION
);
IssuerNotification notification = new IssuerNotification(
UUID.randomUUID(), active.id(), active.issuerId(),
@@ -129,7 +130,8 @@ final class QuestService implements QuestBrowser {
QuestStatus.CANCELLED
);
QuestClaim claim = new QuestClaim(
UUID.randomUUID(), active.id(), active.issuerId(), active.reward(), cancelledAt
UUID.randomUUID(), active.id(), active.issuerId(), active.reward(), cancelledAt,
QuestClaimSource.CANCELLATION
);
Map<UUID, Quest> quests = new LinkedHashMap<>(state.quests());
quests.put(active.id(), cancelled);
@@ -143,6 +145,66 @@ final class QuestService implements QuestBrowser {
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) {
Objects.requireNonNull(recipientId, "recipientId");
return state.notifications().values().stream()
@@ -172,6 +234,14 @@ final class QuestService implements QuestBrowser {
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 {
repository.save(candidate);
state = candidate;
@@ -39,18 +39,29 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
quests, new BukkitQuestCompletionInventory(), notifier
);
QuestCancellationGateway canceller = new QuestCancellationController(quests);
QuestClaimGateway claimant = new QuestClaimController(
quests, new BukkitQuestClaimInventory()
);
QuestCommand questCommand = new QuestCommand(
creator, quests, completer, canceller, clock
creator, quests, completer, canceller, claimant, clock
);
command("quests").setExecutor(questCommand);
command("quests").setTabCompleter(questCommand);
getServer().getPluginManager().registerEvents(notifier, this);
getServer().getPluginManager().registerEvents(
new QuestBoardInteractionListener(
boards, new QuestBoardDialogUi(creator, quests, completer, canceller, clock)
boards, new QuestBoardDialogUi(
creator, quests, completer, canceller, claimant, clock
)
),
this
);
getServer().getScheduler().runTaskTimer(
this,
new QuestExpiryTask(quests, notifier, clock, getLogger()),
0L,
20L
);
getLogger().info(
"Spigot Quest Board enabled with " + boards.size() + " boards and "
+ quests.state().quests().size() + " quests."
@@ -60,7 +60,10 @@ final class YamlQuestRepository implements QuestRepository {
UUID.fromString(requiredString(entry, "quest-id")),
UUID.fromString(requiredString(entry, "owner-id")),
readItems(entry.get("items"), "claim items"),
Instant.parse(requiredString(entry, "created-at"))
Instant.parse(requiredString(entry, "created-at")),
readClaimSource(entry.get("source"), quests.get(
UUID.fromString(requiredString(entry, "quest-id"))
))
);
claims.computeIfAbsent(claim.ownerId(), ignored -> new ArrayList<>()).add(claim);
}
@@ -111,6 +114,7 @@ final class YamlQuestRepository implements QuestRepository {
serialized.put("quest-id", claim.questId().toString());
serialized.put("owner-id", claim.ownerId().toString());
serialized.put("created-at", claim.createdAt().toString());
serialized.put("source", claim.source().name());
serialized.put("items", writeItems(claim.items()));
serializedClaims.add(serialized);
}
@@ -182,6 +186,20 @@ final class YamlQuestRepository implements QuestRepository {
return serializedItems;
}
private static QuestClaimSource readClaimSource(Object value, Quest quest) {
if (value instanceof String source) {
return QuestClaimSource.valueOf(source);
}
if (value != null) {
throw new IllegalArgumentException("Invalid claim source");
}
if (quest == null || quest.status() == QuestStatus.COMPLETED) {
return QuestClaimSource.COMPLETION;
}
return quest.status() == QuestStatus.EXPIRED
? QuestClaimSource.EXPIRATION : QuestClaimSource.CANCELLATION;
}
private static QuestStatus readStatus(Object value) {
if (value == null) {
return QuestStatus.ACTIVE;