feat(quests): add shared active quest browsing
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m7s

This commit is contained in:
dmg
2026-09-04 23:51:34 -04:00
parent 638d2328ab
commit a09a0a3163
17 changed files with 431 additions and 29 deletions
@@ -13,7 +13,8 @@ record Quest(
int requestedAmount,
List<EscrowItem> reward,
Instant createdAt,
Instant expiresAt
Instant expiresAt,
QuestStatus status
) {
Quest {
Objects.requireNonNull(id, "id");
@@ -33,8 +34,25 @@ record Quest(
}
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(expiresAt, "expiresAt");
Objects.requireNonNull(status, "status");
if (!expiresAt.isAfter(createdAt)) {
throw new IllegalArgumentException("Expiration must follow creation");
}
}
Quest(
UUID id,
UUID issuerId,
String issuerName,
String requestedMaterial,
int requestedAmount,
List<EscrowItem> reward,
Instant createdAt,
Instant expiresAt
) {
this(
id, issuerId, issuerName, requestedMaterial, requestedAmount, reward,
createdAt, expiresAt, QuestStatus.ACTIVE
);
}
}
@@ -18,10 +18,12 @@ import org.bukkit.entity.Player;
final class QuestBoardDialogUi implements QuestBoardUi {
private final QuestCreationGateway creator;
private final QuestBrowser browser;
private final Clock clock;
QuestBoardDialogUi(QuestCreationGateway creator, Clock clock) {
QuestBoardDialogUi(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser");
this.clock = Objects.requireNonNull(clock, "clock");
}
@@ -43,12 +45,14 @@ final class QuestBoardDialogUi implements QuestBoardUi {
.lifetime(Duration.ofMinutes(10))
.build()))
.build();
DialogBase base = DialogBase.builder(Component.text("Create a block-delivery quest"))
.externalTitle(Component.text("Quest Board — Create"))
String listing = listingText();
DialogBase base = DialogBase.builder(Component.text("Quest Board"))
.externalTitle(Component.text("Quest Board — Active quests and create"))
.body(List.of(DialogBody.plainMessage(Component.text(
"Hold the reward in your main hand. The entire exact stack, including all item metadata, "
"ACTIVE QUESTS\n" + listing + "\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."
), 420)))
), 800)))
.inputs(List.of(
DialogInput.text("requested_material", Component.text("Requested block"))
.initial("")
@@ -68,6 +72,11 @@ final class QuestBoardDialogUi implements QuestBoardUi {
.type(DialogType.notice(create))));
}
String listingText() {
java.time.Instant now = clock.instant();
return QuestListingFormatter.formatAll(browser.activeQuests(now), now);
}
void submit(Player player, String material, String quantityText) {
final int quantity;
try {
@@ -0,0 +1,21 @@
package games.dmg.spigotquestboard;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
interface QuestBrowser {
List<Quest> activeQuests(Instant now);
default List<String> completableQuestIds(Instant now) {
return activeQuests(now).stream().map(Quest::id).map(UUID::toString).toList();
}
default List<String> cancellableQuestIds(UUID issuerId, Instant now) {
return activeQuests(now).stream()
.filter(quest -> quest.issuerId().equals(issuerId))
.map(Quest::id)
.map(UUID::toString)
.toList();
}
}
@@ -2,6 +2,7 @@ package games.dmg.spigotquestboard;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@@ -14,10 +15,12 @@ import org.bukkit.entity.Player;
final class QuestCommand implements CommandExecutor, TabCompleter {
private static final List<String> QUANTITIES = List.of("1", "16", "32", "64");
private final QuestCreationGateway creator;
private final QuestBrowser browser;
private final Clock clock;
QuestCommand(QuestCreationGateway creator, Clock clock) {
QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser");
this.clock = Objects.requireNonNull(clock, "clock");
}
@@ -25,14 +28,20 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
public boolean onCommand(
CommandSender sender, Command command, String label, String[] arguments
) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can create quests with inventory rewards.");
if (arguments.length == 0
|| (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) {
Instant now = clock.instant();
sender.sendMessage(QuestListingFormatter.formatAll(browser.activeQuests(now), now));
return true;
}
if (arguments.length != 3 || !"create".equalsIgnoreCase(arguments[0])) {
usage(sender);
return true;
}
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can create quests with inventory rewards.");
return true;
}
final int quantity;
try {
quantity = Integer.parseInt(arguments[2]);
@@ -59,7 +68,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
return List.of();
}
if (arguments.length == 1) {
return startsWith(List.of("create"), arguments[0]);
return startsWith(List.of("create", "list"), arguments[0]);
}
if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) {
return creator.suggestBlockMaterials(arguments[1]);
@@ -67,6 +76,15 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
if (arguments.length == 3 && "create".equalsIgnoreCase(arguments[0])) {
return startsWith(QUANTITIES, arguments[2]);
}
if (arguments.length == 2 && "complete".equalsIgnoreCase(arguments[0])) {
return startsWith(browser.completableQuestIds(clock.instant()), arguments[1]);
}
if (arguments.length == 2 && "cancel".equalsIgnoreCase(arguments[0])) {
Player player = (Player) sender;
return startsWith(
browser.cancellableQuestIds(player.getUniqueId(), clock.instant()), arguments[1]
);
}
return List.of();
}
@@ -78,7 +96,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
}
private static void usage(CommandSender sender) {
sender.sendMessage("Usage: /quests create <block> <quantity>");
sender.sendMessage("Usage: /quests [list] | /quests create <block> <quantity>");
sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed.");
}
}
@@ -0,0 +1,70 @@
package games.dmg.spigotquestboard;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
final class QuestListingFormatter {
private QuestListingFormatter() {
}
static String formatAll(List<Quest> quests, Instant now) {
Objects.requireNonNull(quests, "quests");
Objects.requireNonNull(now, "now");
if (quests.isEmpty()) {
return "No active quests.";
}
return quests.stream()
.map(quest -> format(quest, now))
.collect(Collectors.joining("\n"));
}
static String format(Quest quest, Instant now) {
Objects.requireNonNull(quest, "quest");
Objects.requireNonNull(now, "now");
if (quest.status() != QuestStatus.ACTIVE || !now.isBefore(quest.expiresAt())) {
throw new IllegalArgumentException("Only active, unexpired quests can be listed");
}
String reward = quest.reward().stream()
.map(QuestListingFormatter::formatReward)
.collect(Collectors.joining(" + "));
return quest.id() + "" + quest.requestedAmount() + " × "
+ quest.requestedMaterial() + " | Reward: " + reward
+ " | Issuer: " + quest.issuerName()
+ " | Time remaining: " + formatRemaining(now, quest.expiresAt());
}
private static String formatReward(EscrowItem item) {
return item.amount() + " × " + item.material()
+ (item.serializedItem() == null ? "" : " (with exact item data)");
}
private static String formatRemaining(Instant now, Instant expiresAt) {
Duration duration = Duration.between(now, expiresAt);
long seconds = duration.getSeconds() + (duration.getNano() == 0 ? 0 : 1);
long days = seconds / 86_400;
long hours = seconds % 86_400 / 3_600;
long minutes = seconds % 3_600 / 60;
long remainingSeconds = seconds % 60;
StringBuilder result = new StringBuilder();
appendUnit(result, days, "d");
appendUnit(result, hours, "h");
appendUnit(result, minutes, "m");
appendUnit(result, remainingSeconds, "s");
return result.toString();
}
private static void appendUnit(StringBuilder result, long amount, String unit) {
if (amount == 0 && !result.isEmpty()) {
return;
}
if (amount > 0) {
if (!result.isEmpty()) {
result.append(' ');
}
result.append(amount).append(unit);
}
}
}
@@ -10,7 +10,7 @@ import java.util.Locale;
import java.util.Objects;
import java.util.UUID;
final class QuestService {
final class QuestService implements QuestBrowser {
private final QuestRepository repository;
private Map<UUID, Quest> quests;
@@ -45,6 +45,15 @@ final class QuestService {
return quest;
}
@Override
public synchronized List<Quest> activeQuests(Instant now) {
Objects.requireNonNull(now, "now");
return quests.values().stream()
.filter(quest -> quest.status() == QuestStatus.ACTIVE)
.filter(quest -> now.isBefore(quest.expiresAt()))
.toList();
}
synchronized QuestState state() {
return new QuestState(quests);
}
@@ -0,0 +1,8 @@
package games.dmg.spigotquestboard;
enum QuestStatus {
ACTIVE,
COMPLETED,
CANCELLED,
EXPIRED
}
@@ -31,12 +31,12 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
QuestCreationGateway creator = new QuestCreationController(
quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory()
);
QuestCommand questCommand = new QuestCommand(creator, Clock.systemUTC());
QuestCommand questCommand = new QuestCommand(creator, quests, Clock.systemUTC());
command("quests").setExecutor(questCommand);
command("quests").setTabCompleter(questCommand);
getServer().getPluginManager().registerEvents(
new QuestBoardInteractionListener(
boards, new QuestBoardDialogUi(creator, Clock.systemUTC())
boards, new QuestBoardDialogUi(creator, quests, Clock.systemUTC())
),
this
);
@@ -45,7 +45,8 @@ final class YamlQuestRepository implements QuestRepository {
requiredInteger(entry, "requested-amount"),
readRewards(entry.get("reward")),
Instant.parse(requiredString(entry, "created-at")),
Instant.parse(requiredString(entry, "expires-at"))
Instant.parse(requiredString(entry, "expires-at")),
readStatus(entry.get("status"))
);
if (quests.put(id, quest) != null) {
throw new IllegalArgumentException("Duplicate quest id: " + id);
@@ -70,6 +71,7 @@ final class YamlQuestRepository implements QuestRepository {
serialized.put("requested-amount", quest.requestedAmount());
serialized.put("created-at", quest.createdAt().toString());
serialized.put("expires-at", quest.expiresAt().toString());
serialized.put("status", quest.status().name());
List<Map<String, Object>> rewards = new ArrayList<>();
for (EscrowItem reward : quest.reward()) {
Map<String, Object> serializedReward = new LinkedHashMap<>();
@@ -124,6 +126,16 @@ final class YamlQuestRepository implements QuestRepository {
return rewards;
}
private static QuestStatus readStatus(Object value) {
if (value == null) {
return QuestStatus.ACTIVE;
}
if (!(value instanceof String status)) {
throw new IllegalArgumentException("Invalid status");
}
return QuestStatus.valueOf(status);
}
private static String requiredString(Map<?, ?> entry, String key) {
Object value = entry.get(key);
if (!(value instanceof String text) || text.isBlank()) {