diff --git a/README.md b/README.md index f58717e..17f7f45 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The approved behavior is specified in the [OKF knowledge bundle](knowledge/index ## Status -Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. Right-clicking any registered board opens a native dialog where a player can request a block and quantity while escrowing the exact reward stack held in their main hand. Delivery and claims remain 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. Delivery and claims remain under development. ## Requirements diff --git a/knowledge/log.md b/knowledge/log.md index 51f4fdc..5c64de1 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -40,3 +40,10 @@ description: Chronological record of material decisions affecting Spigot Quest B - Escrow preserves full Bukkit item metadata in durable Base64-backed YAML and rolls inventory removal back if persistence fails. - Quests receive UUID identifiers and UTC creation and seven-day expiration timestamps. - Verified 26 tests and the plugin JAR with `./gradlew clean check jar`. + +## 2026-09-05 — Shared quest browsing + +- Added globally shared active-quest listings to every board and to `/quests` and `/quests list`. +- Listings include requested blocks, exact reward summaries, issuer names, and boundary-safe remaining durations. +- Added persisted lifecycle status with backward-compatible active defaults and filtered stale quest IDs from contextual completion and cancellation suggestions. +- Verified 37 tests and the plugin JAR with `./gradlew clean check jar`. diff --git a/knowledge/user-stories/us-004-browse-available-quests.md b/knowledge/user-stories/us-004-browse-available-quests.md index 9f43717..37a6881 100644 --- a/knowledge/user-stories/us-004-browse-available-quests.md +++ b/knowledge/user-stories/us-004-browse-available-quests.md @@ -2,7 +2,7 @@ type: User Story title: "US-004: Browse available quests" description: Let players inspect globally available quests and their essential terms. -status: backlog +status: done --- # US-004: Browse available quests @@ -11,13 +11,13 @@ As a **player**, I want to browse current quests so that I can decide which bloc ## Acceptance criteria -- [ ] Every registered board lists the same active quests. -- [ ] Each listing shows the requested block material, quantity, reward, issuer, and time remaining. -- [ ] Remaining durations are clear and never display an expired quest as active. -- [ ] Completed, cancelled, and expired quests are excluded from the active listing. -- [ ] When player commands are enabled, `/quests` and `/quests list` provide the same active quest information in chat. -- [ ] Listing and autocomplete do not expose stale quest identifiers as completable or cancellable. -- [ ] Automated tests verify filtering, displayed fields, duration boundaries, and command aliases. +- [x] Every registered board lists the same active quests. +- [x] Each listing shows the requested block material, quantity, reward, issuer, and time remaining. +- [x] Remaining durations are clear and never display an expired quest as active. +- [x] Completed, cancelled, and expired quests are excluded from the active listing. +- [x] When player commands are enabled, `/quests` and `/quests list` provide the same active quest information in chat. +- [x] Listing and autocomplete do not expose stale quest identifiers as completable or cancellable. +- [x] Automated tests verify filtering, displayed fields, duration boundaries, and command aliases. ## Related diff --git a/src/main/java/games/dmg/spigotquestboard/Quest.java b/src/main/java/games/dmg/spigotquestboard/Quest.java index 714de8c..2e2280d 100644 --- a/src/main/java/games/dmg/spigotquestboard/Quest.java +++ b/src/main/java/games/dmg/spigotquestboard/Quest.java @@ -13,7 +13,8 @@ record Quest( int requestedAmount, List 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 reward, + Instant createdAt, + Instant expiresAt + ) { + this( + id, issuerId, issuerName, requestedMaterial, requestedAmount, reward, + createdAt, expiresAt, QuestStatus.ACTIVE + ); + } } diff --git a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java index 0b902e3..bec7392 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java @@ -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 { diff --git a/src/main/java/games/dmg/spigotquestboard/QuestBrowser.java b/src/main/java/games/dmg/spigotquestboard/QuestBrowser.java new file mode 100644 index 0000000..15f3be1 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestBrowser.java @@ -0,0 +1,21 @@ +package games.dmg.spigotquestboard; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +interface QuestBrowser { + List activeQuests(Instant now); + + default List completableQuestIds(Instant now) { + return activeQuests(now).stream().map(Quest::id).map(UUID::toString).toList(); + } + + default List cancellableQuestIds(UUID issuerId, Instant now) { + return activeQuests(now).stream() + .filter(quest -> quest.issuerId().equals(issuerId)) + .map(Quest::id) + .map(UUID::toString) + .toList(); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java index be3dbdd..7e15740 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java @@ -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 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 "); + sender.sendMessage("Usage: /quests [list] | /quests create "); sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed."); } } diff --git a/src/main/java/games/dmg/spigotquestboard/QuestListingFormatter.java b/src/main/java/games/dmg/spigotquestboard/QuestListingFormatter.java new file mode 100644 index 0000000..b57cc49 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestListingFormatter.java @@ -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 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); + } + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestService.java b/src/main/java/games/dmg/spigotquestboard/QuestService.java index 23b4b3a..c214a07 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestService.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestService.java @@ -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 quests; @@ -45,6 +45,15 @@ final class QuestService { return quest; } + @Override + public synchronized List 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); } diff --git a/src/main/java/games/dmg/spigotquestboard/QuestStatus.java b/src/main/java/games/dmg/spigotquestboard/QuestStatus.java new file mode 100644 index 0000000..8147ab0 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestStatus.java @@ -0,0 +1,8 @@ +package games.dmg.spigotquestboard; + +enum QuestStatus { + ACTIVE, + COMPLETED, + CANCELLED, + EXPIRED +} diff --git a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java index 2c1a77c..b7c25b2 100644 --- a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java +++ b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java @@ -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 ); diff --git a/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java b/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java index 64a52af..4949364 100644 --- a/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java +++ b/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java @@ -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> rewards = new ArrayList<>(); for (EscrowItem reward : quest.reward()) { Map 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()) { diff --git a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java index f00b1d5..af13f66 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java @@ -20,7 +20,7 @@ final class QuestBoardDialogUiTest { void dialogSubmissionUsesEquivalentCreationFlow() { RecordingCreator creator = new RecordingCreator(false); QuestBoardDialogUi ui = new QuestBoardDialogUi( - creator, Clock.fixed(NOW, ZoneOffset.UTC) + creator, now -> List.of(), Clock.fixed(NOW, ZoneOffset.UTC) ); Player player = mock(Player.class); @@ -35,10 +35,21 @@ final class QuestBoardDialogUiTest { ); } + @Test + void everyOpeningBuildsAListingFromTheCurrentActiveQuests() { + RecordingCreator creator = new RecordingCreator(false); + Quest active = creator.quest(NOW); + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(active), Clock.fixed(NOW, ZoneOffset.UTC) + ); + + assertEquals(QuestListingFormatter.format(active, NOW), ui.listingText()); + } + @Test void persistenceFailureExplainsThatRewardWasRestored() { QuestBoardDialogUi ui = new QuestBoardDialogUi( - new RecordingCreator(true), Clock.fixed(NOW, ZoneOffset.UTC) + new RecordingCreator(true), now -> List.of(), Clock.fixed(NOW, ZoneOffset.UTC) ); Player player = mock(Player.class); @@ -69,10 +80,14 @@ final class QuestBoardDialogUiTest { material = requestedMaterial; quantity = requestedAmount; createdAt = instant; + return quest(instant); + } + + private Quest quest(Instant instant) { return new Quest( UUID.fromString("00000000-0000-0000-0000-000000000010"), UUID.fromString("00000000-0000-0000-0000-000000000001"), - "Issuer", "STONE", requestedAmount, + "Issuer", "STONE", 64, List.of(new EscrowItem("DIAMOND", 1, null)), instant, instant.plusSeconds(604800) ); diff --git a/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java index 3754510..a3cb3a1 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java @@ -4,7 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; 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.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.time.Clock; import java.time.Instant; @@ -35,6 +37,41 @@ final class QuestCommandTest { verify(player).sendMessage(contains("exact held stack")); } + @Test + void bareCommandAndListAliasShowTheSameActiveQuestTerms() { + RecordingCreator creator = new RecordingCreator(); + Quest quest = creator.quest(64); + QuestCommand executor = command(creator, now -> List.of(quest)); + Player player = mock(Player.class); + String expected = QuestListingFormatter.format(quest, NOW); + + assertTrue(executor.onCommand( + player, mock(Command.class), "quests", new String[] {} + )); + assertTrue(executor.onCommand( + player, mock(Command.class), "quests", new String[] {"list"} + )); + + verify(player, times(2)).sendMessage(expected); + } + + @Test + void listNeverDisplaysExpiredOrStaleQuestIdentifiers() { + RecordingCreator creator = new RecordingCreator(); + Quest active = creator.quest(64); + Quest expired = new Quest( + UUID.randomUUID(), UUID.randomUUID(), "OldIssuer", "DIRT", 1, + List.of(new EscrowItem("COAL", 1, null)), NOW.minusSeconds(604800), NOW + ); + QuestCommand executor = command(creator, instant -> List.of(active)); + Player player = mock(Player.class); + + executor.onCommand(player, mock(Command.class), "quests", new String[] {"list"}); + + verify(player).sendMessage(contains(active.id().toString())); + verify(player, org.mockito.Mockito.never()).sendMessage(contains(expired.id().toString())); + } + @Test void invalidQuantityNeverReachesCreation() { RecordingCreator creator = new RecordingCreator(); @@ -49,6 +86,29 @@ final class QuestCommandTest { verify(player).sendMessage("Quest quantity must be a positive whole number."); } + @Test + void questIdentifierAutocompleteOnlyUsesActiveAndOwnedQuests() { + RecordingCreator creator = new RecordingCreator(); + UUID issuerId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + Quest active = creator.quest(64); + QuestCommand executor = command(creator, now -> List.of(active)); + Player issuer = mock(Player.class); + when(issuer.getUniqueId()).thenReturn(issuerId); + Player otherPlayer = mock(Player.class); + when(otherPlayer.getUniqueId()).thenReturn(UUID.randomUUID()); + Command command = mock(Command.class); + + assertEquals(List.of(active.id().toString()), executor.onTabComplete( + issuer, command, "quests", new String[] {"complete", ""} + )); + assertEquals(List.of(active.id().toString()), executor.onTabComplete( + issuer, command, "quests", new String[] {"cancel", ""} + )); + assertTrue(executor.onTabComplete( + otherPlayer, command, "quests", new String[] {"cancel", ""} + ).isEmpty()); + } + @Test void autocompleteIsPlayerOnlyAndContextual() { RecordingCreator creator = new RecordingCreator(); @@ -58,6 +118,8 @@ final class QuestCommandTest { assertEquals(List.of("create"), executor.onTabComplete(player, command, "quests", new String[] {"cr"})); + assertEquals(List.of("list"), + executor.onTabComplete(player, command, "quests", new String[] {"li"})); assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete( player, command, "quests", new String[] {"create", "sto"} )); @@ -71,7 +133,11 @@ final class QuestCommandTest { } private static QuestCommand command(RecordingCreator creator) { - return new QuestCommand(creator, Clock.fixed(NOW, ZoneOffset.UTC)); + return command(creator, now -> List.of()); + } + + private static QuestCommand command(RecordingCreator creator, QuestBrowser browser) { + return new QuestCommand(creator, browser, Clock.fixed(NOW, ZoneOffset.UTC)); } private static final class RecordingCreator implements QuestCreationGateway { @@ -91,12 +157,17 @@ final class QuestCommandTest { quantity = requestedAmount; this.createdAt = createdAt; calls++; + return quest(requestedAmount); + } + + private Quest quest(int requestedAmount) { + Instant instant = createdAt == null ? NOW : createdAt; return new Quest( UUID.fromString("00000000-0000-0000-0000-000000000010"), UUID.fromString("00000000-0000-0000-0000-000000000001"), "Issuer", "STONE", requestedAmount, List.of(new EscrowItem("DIAMOND", 1, null)), - createdAt, createdAt.plusSeconds(604800) + instant, instant.plusSeconds(604800) ); } diff --git a/src/test/java/games/dmg/spigotquestboard/QuestListingFormatterTest.java b/src/test/java/games/dmg/spigotquestboard/QuestListingFormatterTest.java new file mode 100644 index 0000000..d61d468 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestListingFormatterTest.java @@ -0,0 +1,57 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class QuestListingFormatterTest { + private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z"); + + @Test + void describesEveryEssentialTermAndEachExactRewardStack() { + Quest quest = quest( + NOW.plusSeconds(90061), + List.of( + new EscrowItem("DIAMOND", 3, null), + new EscrowItem("DIAMOND_SWORD", 1, "opaque-exact-data") + ) + ); + + String listing = QuestListingFormatter.format(quest, NOW); + + assertTrue(listing.contains(quest.id().toString())); + assertTrue(listing.contains("64 × STONE")); + assertTrue(listing.contains("3 × DIAMOND")); + assertTrue(listing.contains("1 × DIAMOND_SWORD (with exact item data)")); + assertTrue(listing.contains("Issuer: Issuer")); + assertTrue(listing.contains("Time remaining: 1d 1h 1m 1s")); + } + + @Test + void roundsAnActiveSubsecondBoundaryUpToOneSecond() { + Quest quest = quest(NOW.plusNanos(1), List.of(new EscrowItem("DIAMOND", 1, null))); + + String listing = QuestListingFormatter.format(quest, NOW); + + assertTrue(listing.contains("Time remaining: 1s")); + assertFalse(listing.contains("0s")); + } + + @Test + void formatsAnEmptyListingClearly() { + assertEquals("No active quests.", QuestListingFormatter.formatAll(List.of(), NOW)); + } + + private static Quest quest(Instant expiresAt, List rewards) { + return new Quest( + UUID.fromString("00000000-0000-0000-0000-000000000010"), + UUID.fromString("00000000-0000-0000-0000-000000000001"), + "Issuer", "STONE", 64, rewards, NOW.minusSeconds(60), expiresAt + ); + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestServiceTest.java b/src/test/java/games/dmg/spigotquestboard/QuestServiceTest.java index b0d8167..7539a8a 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestServiceTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestServiceTest.java @@ -66,6 +66,44 @@ final class QuestServiceTest { assertEquals(0, repository.saveCount); } + @Test + void listsOnlyQuestsThatHaveNotReachedTheirExpiration() throws Exception { + MemoryQuestRepository repository = new MemoryQuestRepository(); + QuestService service = new QuestService(repository); + Instant createdAt = Instant.parse("2026-09-05T00:00:00Z"); + Quest quest = service.create( + UUID.randomUUID(), "Issuer", "STONE", 1, + List.of(new EscrowItem("DIAMOND", 1, null)), createdAt + ); + + assertEquals(List.of(quest), service.activeQuests(quest.expiresAt().minusNanos(1))); + assertTrue(service.activeQuests(quest.expiresAt()).isEmpty()); + } + + @Test + void activeListingExcludesNonActiveLifecycleStatuses() throws Exception { + Instant now = Instant.parse("2026-09-06T00:00:00Z"); + Quest active = questWithStatus(QuestStatus.ACTIVE); + Quest completed = questWithStatus(QuestStatus.COMPLETED); + Quest cancelled = questWithStatus(QuestStatus.CANCELLED); + Quest expired = questWithStatus(QuestStatus.EXPIRED); + MemoryQuestRepository repository = new MemoryQuestRepository(); + repository.state = new QuestState(java.util.stream.Stream.of( + active, completed, cancelled, expired + ).collect(java.util.stream.Collectors.toMap( + Quest::id, quest -> quest, (left, right) -> left, java.util.LinkedHashMap::new + ))); + + QuestService service = new QuestService(repository); + + assertEquals(List.of(active), service.activeQuests(now)); + assertEquals(List.of(active.id().toString()), service.completableQuestIds(now)); + assertEquals( + List.of(active.id().toString()), service.cancellableQuestIds(active.issuerId(), now) + ); + assertTrue(service.cancellableQuestIds(UUID.randomUUID(), now).isEmpty()); + } + @Test void persistenceFailureDoesNotPublishQuest() throws Exception { QuestRepository repository = new QuestRepository() { @@ -83,6 +121,15 @@ final class QuestServiceTest { assertTrue(service.state().quests().isEmpty()); } + private static Quest questWithStatus(QuestStatus status) { + Instant createdAt = Instant.parse("2026-09-05T00:00:00Z"); + return new Quest( + UUID.randomUUID(), UUID.randomUUID(), "Issuer", "STONE", 1, + List.of(new EscrowItem("DIAMOND", 1, null)), createdAt, + createdAt.plus(7, ChronoUnit.DAYS), status + ); + } + private static final class MemoryQuestRepository implements QuestRepository { private QuestState state = QuestState.empty(); private int saveCount; diff --git a/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java b/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java index 67ae028..e704f4e 100644 --- a/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java +++ b/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java @@ -53,6 +53,46 @@ final class YamlQuestRepositoryTest { assertTrue(yaml.contains(itemData)); } + @Test + void missingLifecycleStatusDefaultsToActiveForExistingYaml() 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: 64 + created-at: '2026-09-05T03:00:00Z' + expires-at: '2026-09-12T03:00:00Z' + reward: + - material: DIAMOND + amount: 2 + """); + + Quest quest = new YamlQuestRepository(path).load().quests().values().iterator().next(); + + assertEquals(QuestStatus.ACTIVE, quest.status()); + } + + @Test + void roundTripsNonActiveLifecycleStatus() throws Exception { + Path path = temporaryDirectory.resolve("quests.yml"); + YamlQuestRepository repository = new YamlQuestRepository(path); + UUID id = UUID.fromString("00000000-0000-0000-0000-000000000010"); + Quest quest = new Quest( + id, UUID.randomUUID(), "Issuer", "STONE", 1, + List.of(new EscrowItem("DIAMOND", 1, null)), + Instant.parse("2026-09-05T03:00:00Z"), + Instant.parse("2026-09-12T03:00:00Z"), QuestStatus.COMPLETED + ); + + repository.save(new QuestState(Map.of(id, quest))); + + assertEquals(QuestStatus.COMPLETED, repository.load().quests().get(id).status()); + assertTrue(Files.readString(path).contains("status: COMPLETED")); + } + @Test void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception { Path path = temporaryDirectory.resolve("quests.yml");