diff --git a/README.md b/README.md index 37b1b05..142a8de 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. 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 ` 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 `. 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. +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 compact native dashboard with dedicated browsing, creation, and claim screens. 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 ` 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 `. 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 diff --git a/knowledge/log.md b/knowledge/log.md index 4eb54e3..6fdc6ff 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -76,3 +76,10 @@ description: Chronological record of material decisions affecting Spigot Quest B - Added persistent `/questadmin commands enable|disable` control with permission-aware autocomplete and failure-safe updates. - Kept administrative board creation available independently of the player-command setting. - Verified 82 tests and the plugin JAR with `./gradlew clean check jar`. + +## 2026-09-05 — Screen-fitting quest-board dialogs + +- Replaced the clipped 800-pixel combined board screen with a compact 420-pixel dashboard. +- Split browsing, creation, and claims into dedicated native dialog screens with Back navigation. +- Presented active quests as individual detail dialogs while retaining completion and issuer cancellation actions. +- Verified 88 tests, including a 250-quest navigation case, and the plugin JAR with `./gradlew clean check jar`. diff --git a/knowledge/user-stories/index.md b/knowledge/user-stories/index.md index 42ef580..da87490 100644 --- a/knowledge/user-stories/index.md +++ b/knowledge/user-stories/index.md @@ -14,3 +14,5 @@ description: Catalog of user stories for the Spigot Quest Board plugin. 6. [US-006: Cancel an owned quest](us-006-cancel-an-owned-quest.md) 7. [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md) 8. [US-008: Control player quest commands](us-008-control-player-quest-commands.md) +9. [US-009: Use a screen-fitting quest-board interface](us-009-use-a-screen-fitting-quest-board-interface.md) +10. [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.md) diff --git a/knowledge/user-stories/us-009-use-a-screen-fitting-quest-board-interface.md b/knowledge/user-stories/us-009-use-a-screen-fitting-quest-board-interface.md new file mode 100644 index 0000000..40d4d12 --- /dev/null +++ b/knowledge/user-stories/us-009-use-a-screen-fitting-quest-board-interface.md @@ -0,0 +1,30 @@ +--- +type: User Story +title: "US-009: Use a screen-fitting quest-board interface" +description: Split the oversized quest-board dialog into compact navigable screens that fit the player's display. +status: done +--- + +# US-009: Use a screen-fitting quest-board interface + +As a **player**, I want the quest-board interface to fit on screen so that I can read quest information and reach every action. + +## Acceptance criteria + +- [x] The oversized combined dialog is replaced by a compact dashboard. +- [x] Dialog message bodies use a maximum width of 420 pixels. +- [x] Browsing, quest creation, and pending claims use dedicated screens rather than one combined body. +- [x] Active quests are presented as individual navigable entries instead of one unbounded text listing. +- [x] Each quest detail shows its issuer, requested material and quantity, reward, and time remaining. +- [x] Completion, issuer-only cancellation, creation, and claim collection remain available through the board. +- [x] Nested screens provide Back controls that return toward the quest-board dashboard. +- [x] Large active-quest collections remain navigable without producing an unbounded single dialog. +- [x] Automated tests verify width limits, screen separation, navigation, displayed content, and action routing. + +## Related + +- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) +- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md) +- [US-004: Browse available quests](us-004-browse-available-quests.md) +- [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md) +- [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md) diff --git a/knowledge/user-stories/us-010-generate-a-physical-quest-board.md b/knowledge/user-stories/us-010-generate-a-physical-quest-board.md new file mode 100644 index 0000000..1ebbd0b --- /dev/null +++ b/knowledge/user-stories/us-010-generate-a-physical-quest-board.md @@ -0,0 +1,29 @@ +--- +type: User Story +title: "US-010: Generate a physical quest-board structure" +description: Let an administrator generate and register a decorative oak quest board at a targeted ground anchor. +status: backlog +--- + +# US-010: Generate a physical quest-board structure + +As an **administrator**, I want quest-board creation to optionally construct a recognizable physical board so that I do not need to build each board manually. + +## Acceptance criteria + +- [ ] `/questadmin createboard physical` generates and registers a physical quest board while the existing `/questadmin createboard` behavior remains unchanged. +- [ ] The targeted block is treated as the ground anchor and remains unchanged. +- [ ] The generated board faces the administrator and is five blocks wide and four blocks tall. +- [ ] The outer columns use oak-log pillars and the center uses oak planks. +- [ ] Oak wall signs on the front display decorative obfuscated or gibberish text. +- [ ] Clicking generated visible planks or signs opens the same globally shared quest-board interface. +- [ ] Generation requires empty space and refuses to overwrite an existing structure. +- [ ] Structure generation and interaction-location registration form one failure-safe transaction; failure restores changed blocks and registers nothing. +- [ ] Generated interaction locations persist across restarts and remain compatible with existing registered boards. +- [ ] The `physical` argument is offered through permission-aware command autocomplete. +- [ ] Automated tests verify geometry, facing direction, obstruction handling, rollback, registration, persistence, and autocomplete. + +## Related + +- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) +- [US-009: Use a screen-fitting quest-board interface](us-009-use-a-screen-fitting-quest-board-interface.md) diff --git a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogSpec.java b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogSpec.java new file mode 100644 index 0000000..f6e7d22 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogSpec.java @@ -0,0 +1,87 @@ +package games.dmg.spigotquestboard; + +import java.util.List; +import java.util.Objects; + +/** A server-independent description of the quest-board dialog hierarchy. */ +record QuestBoardDialogSpec( + int maximumBodyWidth, + Screen dashboard, + Screen browse, + Screen create, + Screen claims, + List questEntries +) { + QuestBoardDialogSpec { + if (maximumBodyWidth <= 0) { + throw new IllegalArgumentException("Maximum body width must be positive"); + } + Objects.requireNonNull(dashboard, "dashboard"); + Objects.requireNonNull(browse, "browse"); + Objects.requireNonNull(create, "create"); + Objects.requireNonNull(claims, "claims"); + questEntries = List.copyOf(Objects.requireNonNull(questEntries, "questEntries")); + } + + List dashboardActions() { + return dashboard.actions().stream().map(Action::label).toList(); + } + + record Screen( + String title, + String externalTitle, + String message, + List actions, + List inputs, + boolean hasBackAction + ) { + Screen { + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(externalTitle, "externalTitle"); + Objects.requireNonNull(message, "message"); + actions = List.copyOf(Objects.requireNonNull(actions, "actions")); + inputs = List.copyOf(Objects.requireNonNull(inputs, "inputs")); + } + } + + record QuestEntry( + String title, + String message, + List actions, + boolean hasBackAction + ) { + QuestEntry { + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(message, "message"); + actions = List.copyOf(Objects.requireNonNull(actions, "actions")); + } + } + + record Action(String label, Route route, String questId) { + Action { + Objects.requireNonNull(label, "label"); + Objects.requireNonNull(route, "route"); + } + } + + record Input(String key, String label, String initial, int maximumLength) { + Input { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(label, "label"); + Objects.requireNonNull(initial, "initial"); + if (maximumLength <= 0) { + throw new IllegalArgumentException("Maximum input length must be positive"); + } + } + } + + enum Route { + OPEN_BROWSE, + OPEN_CREATE, + OPEN_CLAIMS, + SUBMIT_CREATE, + COMPLETE, + CANCEL, + COLLECT + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java index 59ff47f..0d48a02 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java @@ -1,16 +1,17 @@ package games.dmg.spigotquestboard; import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.registry.RegistryKey; import io.papermc.paper.registry.data.dialog.ActionButton; import io.papermc.paper.registry.data.dialog.DialogBase; import io.papermc.paper.registry.data.dialog.action.DialogAction; import io.papermc.paper.registry.data.dialog.body.DialogBody; import io.papermc.paper.registry.data.dialog.input.DialogInput; import io.papermc.paper.registry.data.dialog.type.DialogType; +import io.papermc.paper.registry.set.RegistrySet; import java.io.IOException; import java.time.Clock; import java.time.Duration; -import java.util.ArrayList; import java.util.List; import java.util.Objects; import net.kyori.adventure.text.Component; @@ -18,6 +19,12 @@ import net.kyori.adventure.text.event.ClickCallback; import org.bukkit.entity.Player; final class QuestBoardDialogUi implements QuestBoardUi { + private static final int DIALOG_WIDTH = 420; + private static final ClickCallback.Options CALLBACK_OPTIONS = ClickCallback.Options.builder() + .uses(1) + .lifetime(Duration.ofMinutes(10)) + .build(); + private final QuestCreationGateway creator; private final QuestBrowser browser; private final QuestCompletionGateway completer; @@ -66,9 +73,131 @@ final class QuestBoardDialogUi implements QuestBoardUi { @Override public void open(Player player) { + Objects.requireNonNull(player, "player"); + player.showDialog(renderDashboard(specification(player), player)); + } + + QuestBoardDialogSpec specification(Player player) { + Objects.requireNonNull(player, "player"); + java.time.Instant now = clock.instant(); + List entries = browser.activeQuests(now).stream() + .map(quest -> questEntry(quest, player, now)) + .toList(); + List dashboardActions = List.of( + action("Browse quests", QuestBoardDialogSpec.Route.OPEN_BROWSE), + action("Create quest", QuestBoardDialogSpec.Route.OPEN_CREATE), + action("Pending claims", QuestBoardDialogSpec.Route.OPEN_CLAIMS) + ); + QuestBoardDialogSpec.Screen dashboard = new QuestBoardDialogSpec.Screen( + "Quest Board", + "Quest Board", + "Choose what you want to do.", + dashboardActions, + List.of(), + false + ); + QuestBoardDialogSpec.Screen browse = new QuestBoardDialogSpec.Screen( + "Browse Quests", + "Browse quests", + entries.isEmpty() + ? "No active quests." + : "Select a quest to view its request, reward, issuer, and time remaining.", + List.of(), + List.of(), + true + ); + QuestBoardDialogSpec.Screen create = new QuestBoardDialogSpec.Screen( + "Create Quest", + "Create quest", + "Hold the reward in your main hand. The entire exact stack, including all item " + + "metadata, is escrowed only if the quest saves successfully.", + List.of(action("Create quest", QuestBoardDialogSpec.Route.SUBMIT_CREATE)), + List.of( + new QuestBoardDialogSpec.Input("requested_material", "Requested block", "", 64), + new QuestBoardDialogSpec.Input("requested_quantity", "Quantity", "64", 10) + ), + true + ); + QuestBoardDialogSpec.Screen claims = new QuestBoardDialogSpec.Screen( + "Pending Claims", + "Pending claims", + claimListingText(player), + claimant == null + ? List.of() + : List.of(action("Collect pending claims", QuestBoardDialogSpec.Route.COLLECT)), + List.of(), + true + ); + return new QuestBoardDialogSpec( + DIALOG_WIDTH, dashboard, browse, create, claims, entries + ); + } + + private QuestBoardDialogSpec.QuestEntry questEntry( + Quest quest, + Player player, + java.time.Instant now + ) { + List actions = new java.util.ArrayList<>(); + if (completer != null) { + actions.add(new QuestBoardDialogSpec.Action( + "Complete quest", QuestBoardDialogSpec.Route.COMPLETE, quest.id().toString() + )); + } + if (canceller != null && quest.issuerId().equals(player.getUniqueId())) { + actions.add(new QuestBoardDialogSpec.Action( + "Cancel quest", QuestBoardDialogSpec.Route.CANCEL, quest.id().toString() + )); + } + return new QuestBoardDialogSpec.QuestEntry( + quest.requestedAmount() + " × " + quest.requestedMaterial(), + QuestListingFormatter.format(quest, now), + actions, + true + ); + } + + private static QuestBoardDialogSpec.Action action( + String label, + QuestBoardDialogSpec.Route route + ) { + return new QuestBoardDialogSpec.Action(label, route, null); + } + + private Dialog renderDashboard(QuestBoardDialogSpec specification, Player player) { + List screens = List.of( + renderBrowse(specification, player), + renderCreate(specification, player), + renderClaims(specification, player) + ); + return dialog( + specification.dashboard(), + DialogType.dialogList( + RegistrySet.valueSet(RegistryKey.DIALOG, screens), null, 1, 260 + ) + ); + } + + private Dialog renderBrowse(QuestBoardDialogSpec specification, Player player) { + ActionButton back = backToDashboard(player); + if (specification.questEntries().isEmpty()) { + return dialog(specification.browse(), DialogType.notice(back)); + } + List quests = specification.questEntries().stream() + .map(entry -> renderQuest(entry, player)) + .toList(); + return dialog( + specification.browse(), + DialogType.dialogList( + RegistrySet.valueSet(RegistryKey.DIALOG, quests), back, 1, 260 + ) + ); + } + + private Dialog renderCreate(QuestBoardDialogSpec specification, Player player) { ActionButton create = ActionButton.builder(Component.text("Create quest")) .tooltip(Component.text("Escrow your held stack and publish this quest")) - .width(150) + .width(180) .action(DialogAction.customClick((response, audience) -> { if (audience instanceof Player respondingPlayer) { submit( @@ -77,64 +206,111 @@ final class QuestBoardDialogUi implements QuestBoardUi { response.getText("requested_quantity") ); } - }, ClickCallback.Options.builder() - .uses(1) - .lifetime(Duration.ofMinutes(10)) - .build())) + }, CALLBACK_OPTIONS)) .build(); - java.time.Instant now = clock.instant(); - List activeQuests = browser.activeQuests(now); - String listing = QuestListingFormatter.formatAll(activeQuests, now); - String claimListing = claimListingText(player); - List 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()); + return dialog( + specification.create(), + DialogType.multiAction(List.of(create), backToDashboard(player), 1) + ); + } + + private Dialog renderClaims(QuestBoardDialogSpec specification, Player player) { + ActionButton back = backToDashboard(player); + if (claimant == null) { + return dialog(specification.claims(), DialogType.notice(back)); } - for (Quest quest : activeQuests) { - if (completer != null) { - actions.add(completionButton(quest)); - } - if (canceller != null && quest.issuerId().equals(player.getUniqueId())) { - actions.add(cancellationButton(quest)); - } - } - DialogBase base = DialogBase.builder(Component.text("Quest Board")) - .externalTitle(Component.text("Quest Board — Active quests and create")) - .body(List.of(DialogBody.plainMessage(Component.text( - "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))) - .inputs(List.of( - DialogInput.text("requested_material", Component.text("Requested block")) - .initial("") - .maxLength(64) - .build(), - DialogInput.text("requested_quantity", Component.text("Quantity")) - .initial("64") - .maxLength(10) - .build() - )) + ActionButton collect = callbackButton( + "Collect pending claims", + "Collect delivered blocks and returned rewards", + 250, + respondingPlayer -> submitClaim(respondingPlayer) + ); + return dialog( + specification.claims(), DialogType.multiAction(List.of(collect), back, 1) + ); + } + + private Dialog renderQuest(QuestBoardDialogSpec.QuestEntry entry, Player player) { + QuestBoardDialogSpec.Screen screen = new QuestBoardDialogSpec.Screen( + entry.title(), entry.title(), entry.message(), entry.actions(), List.of(), true + ); + ActionButton back = backToBrowse(player); + List actions = entry.actions().stream() + .map(action -> renderQuestAction(action)) + .toList(); + return dialog( + screen, + actions.isEmpty() ? DialogType.notice(back) : DialogType.multiAction(actions, back, 1) + ); + } + + private ActionButton renderQuestAction(QuestBoardDialogSpec.Action action) { + return switch (action.route()) { + case COMPLETE -> callbackButton( + action.label(), "Deliver the requested blocks", 180, + player -> submitCompletion(player, action.questId()) + ); + case CANCEL -> callbackButton( + action.label(), "Return the escrowed reward to pending claims", 180, + player -> submitCancellation(player, action.questId()) + ); + default -> throw new IllegalArgumentException("Unsupported quest action route"); + }; + } + + private Dialog dialog(QuestBoardDialogSpec.Screen screen, DialogType type) { + DialogBase.Builder base = DialogBase.builder(Component.text(screen.title())) + .externalTitle(Component.text(screen.externalTitle())) + .body(List.of(DialogBody.plainMessage(Component.text(screen.message()), DIALOG_WIDTH))) .canCloseWithEscape(true) .pause(false) - .afterAction(DialogBase.DialogAfterAction.CLOSE) + .afterAction(DialogBase.DialogAfterAction.CLOSE); + if (!screen.inputs().isEmpty()) { + base.inputs(screen.inputs().stream().map(input -> DialogInput.text( + input.key(), Component.text(input.label()) + ) + .initial(input.initial()) + .maxLength(input.maximumLength()) + .build()).toList()); + } + DialogBase builtBase = base.build(); + return Dialog.create(factory -> factory.empty().base(builtBase).type(type)); + } + + private ActionButton backToDashboard(Player player) { + return callbackButton( + "Back", "Return to the quest-board dashboard", 120, + respondingPlayer -> respondingPlayer.showDialog( + renderDashboard(specification(respondingPlayer), respondingPlayer) + ) + ); + } + + private ActionButton backToBrowse(Player player) { + return callbackButton( + "Back", "Return to active quests", 120, + respondingPlayer -> { + QuestBoardDialogSpec current = specification(respondingPlayer); + respondingPlayer.showDialog(renderBrowse(current, respondingPlayer)); + } + ); + } + + private ActionButton callbackButton( + String label, + String tooltip, + int width, + java.util.function.Consumer callback + ) { + return ActionButton.builder(Component.text(label)) + .tooltip(Component.text(tooltip)) + .width(width) + .action(DialogAction.customClick((response, audience) -> { + if (audience instanceof Player respondingPlayer) { + callback.accept(respondingPlayer); + } + }, CALLBACK_OPTIONS)) .build(); - player.showDialog(Dialog.create(factory -> factory.empty() - .base(base) - .type(DialogType.multiAction(actions).columns(1).build()))); } String listingText() { @@ -161,42 +337,6 @@ final class QuestBoardDialogUi implements QuestBoardUi { }).reduce((left, right) -> left + "\n" + right).orElseThrow(); } - private ActionButton completionButton(Quest quest) { - String id = quest.id().toString(); - return ActionButton.builder(Component.text( - "Complete " + quest.requestedAmount() + " " + quest.requestedMaterial() - )) - .tooltip(Component.text("Deliver blocks for quest " + id)) - .width(250) - .action(DialogAction.customClick((response, audience) -> { - if (audience instanceof Player respondingPlayer) { - submitCompletion(respondingPlayer, id); - } - }, ClickCallback.Options.builder() - .uses(1) - .lifetime(Duration.ofMinutes(10)) - .build())) - .build(); - } - - private ActionButton cancellationButton(Quest quest) { - String id = quest.id().toString(); - return ActionButton.builder(Component.text( - "Cancel " + quest.requestedAmount() + " " + quest.requestedMaterial() - )) - .tooltip(Component.text("Return the escrowed reward to claims for quest " + id)) - .width(250) - .action(DialogAction.customClick((response, audience) -> { - if (audience instanceof Player respondingPlayer) { - submitCancellation(respondingPlayer, id); - } - }, ClickCallback.Options.builder() - .uses(1) - .lifetime(Duration.ofMinutes(10)) - .build())) - .build(); - } - List cancellableQuestIds(Player player) { return browser.cancellableQuestIds(player.getUniqueId(), clock.instant()); } diff --git a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java index 9c4e5b8..a71e283 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java @@ -1,8 +1,11 @@ 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 static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.io.IOException; import java.time.Clock; @@ -16,6 +19,144 @@ import org.junit.jupiter.api.Test; final class QuestBoardDialogUiTest { private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z"); + @Test + void dashboardUsesCompactDedicatedNavigableScreens() { + RecordingCreator creator = new RecordingCreator(false); + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(creator.quest(NOW)), Clock.fixed(NOW, ZoneOffset.UTC) + ); + + QuestBoardDialogSpec specification = ui.specification(mock(Player.class)); + + assertEquals(420, specification.maximumBodyWidth()); + assertEquals(List.of("Browse quests", "Create quest", "Pending claims"), + specification.dashboardActions()); + assertEquals(1, specification.questEntries().size()); + org.junit.jupiter.api.Assertions.assertTrue( + specification.questEntries().getFirst().hasBackAction() + ); + } + + @Test + void dedicatedScreensKeepContentSeparateAndProvideBackNavigation() { + RecordingCreator creator = new RecordingCreator(false); + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(creator.quest(NOW)), null, null, player -> + new ClaimCollectionResult(0, 0), Clock.fixed(NOW, ZoneOffset.UTC) + ); + + QuestBoardDialogSpec specification = ui.specification(mock(Player.class)); + + assertEquals("Choose what you want to do.", specification.dashboard().message()); + assertTrue(specification.browse().message().contains("Select a quest")); + assertTrue(specification.create().message().contains("reward in your main hand")); + assertEquals("No pending claims.", specification.claims().message()); + assertFalse(specification.dashboard().hasBackAction()); + assertTrue(specification.browse().hasBackAction()); + assertTrue(specification.create().hasBackAction()); + assertTrue(specification.claims().hasBackAction()); + assertTrue(specification.questEntries().stream().allMatch( + QuestBoardDialogSpec.QuestEntry::hasBackAction + )); + } + + @Test + void questDetailsShowRequiredContentAndRouteAvailableActions() { + RecordingCreator creator = new RecordingCreator(false); + Quest quest = creator.quest(NOW); + Player issuer = mock(Player.class); + when(issuer.getUniqueId()).thenReturn(quest.issuerId()); + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(quest), new RecordingCompleter(), new RecordingCanceller(), + null, Clock.fixed(NOW, ZoneOffset.UTC) + ); + + QuestBoardDialogSpec specification = ui.specification(issuer); + QuestBoardDialogSpec.QuestEntry entry = specification.questEntries().getFirst(); + + assertTrue(entry.message().contains("64 × STONE")); + assertTrue(entry.message().contains("Reward: 1 × DIAMOND")); + assertTrue(entry.message().contains("Issuer: Issuer")); + assertTrue(entry.message().contains("Time remaining: 7d")); + assertEquals( + List.of(QuestBoardDialogSpec.Route.COMPLETE, QuestBoardDialogSpec.Route.CANCEL), + entry.actions().stream().map(QuestBoardDialogSpec.Action::route).toList() + ); + assertTrue(entry.actions().stream().allMatch( + action -> quest.id().toString().equals(action.questId()) + )); + } + + @Test + void dashboardCreationAndClaimsDeclareTheirActionRoutesAndInputs() { + RecordingCreator creator = new RecordingCreator(false); + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(), null, null, player -> + new ClaimCollectionResult(0, 0), Clock.fixed(NOW, ZoneOffset.UTC) + ); + + QuestBoardDialogSpec specification = ui.specification(mock(Player.class)); + + assertEquals( + List.of( + QuestBoardDialogSpec.Route.OPEN_BROWSE, + QuestBoardDialogSpec.Route.OPEN_CREATE, + QuestBoardDialogSpec.Route.OPEN_CLAIMS + ), + specification.dashboard().actions().stream() + .map(QuestBoardDialogSpec.Action::route) + .toList() + ); + assertEquals(List.of("requested_material", "requested_quantity"), + specification.create().inputs().stream() + .map(QuestBoardDialogSpec.Input::key) + .toList()); + assertEquals(QuestBoardDialogSpec.Route.SUBMIT_CREATE, + specification.create().actions().getFirst().route()); + assertEquals(QuestBoardDialogSpec.Route.COLLECT, + specification.claims().actions().getFirst().route()); + } + + @Test + void largeQuestCollectionsRemainIndividualNavigableEntries() { + RecordingCreator creator = new RecordingCreator(false); + List quests = java.util.stream.IntStream.range(0, 250) + .mapToObj(index -> new Quest( + new UUID(0, index + 100L), new UUID(0, index + 1L), "Issuer " + index, + "STONE", index + 1, List.of(new EscrowItem("DIAMOND", 1, null)), + NOW, NOW.plusSeconds(604800) + )) + .toList(); + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> quests, Clock.fixed(NOW, ZoneOffset.UTC) + ); + + QuestBoardDialogSpec specification = ui.specification(mock(Player.class)); + + assertEquals(250, specification.questEntries().size()); + assertTrue(specification.questEntries().stream().allMatch( + QuestBoardDialogSpec.QuestEntry::hasBackAction + )); + assertFalse(specification.browse().message().contains(quests.getLast().id().toString())); + } + + @Test + void nonIssuersCannotReceiveCancellationRoutes() { + RecordingCreator creator = new RecordingCreator(false); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(creator.quest(NOW)), new RecordingCompleter(), + new RecordingCanceller(), Clock.fixed(NOW, ZoneOffset.UTC) + ); + + QuestBoardDialogSpec.QuestEntry entry = ui.specification(player) + .questEntries().getFirst(); + + assertEquals(List.of(QuestBoardDialogSpec.Route.COMPLETE), + entry.actions().stream().map(QuestBoardDialogSpec.Action::route).toList()); + } + @Test void disabledPlayerCommandsDoNotGateBoardUiGateways() throws Exception { PlayerCommandSettings settings = new PlayerCommandSettings(