2 Commits
Author SHA1 Message Date
dmg 3bd2dc7d14 feat(quests): support requests for all inventory items
Release / release (push) Successful in 2m46s
CI / build (push) Successful in 1m19s
2026-09-09 21:50:16 -04:00
dmg de8ff83c3c feat(notifications): add login quest counts and claim reminders
Release / release (push) Successful in 2m40s
CI / build (push) Successful in 1m17s
2026-09-06 21:34:24 -04:00
29 changed files with 304 additions and 64 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
# Spigot Quest Board # Spigot Quest Board
A Purpur 26.2 plugin for shared physical quest boards. Players can escrow rewards for requested block items, deliver those blocks at any quest board, and claim delivered or returned items safely. A Purpur 26.2 plugin for shared physical quest boards. Players can escrow rewards for requested items, deliver those items at any quest board, and claim delivered or returned items safely.
The approved behavior is specified in the [OKF knowledge bundle](knowledge/index.md). The approved behavior is specified in the [OKF knowledge bundle](knowledge/index.md).
## Status ## Status
Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. They can instead run `/questadmin createboard physical` to generate a five-wide oak board above the targeted ground anchor, with glowing centered title and browsing-instruction signs above three decorative signs; its visible panel blocks and all five signs are registered. 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; custom reward names are shown together with their material type. Players can complete quests at any board or with `/quests complete <quest>` by delivering the required blocks. Exact escrowed rewards are granted immediately, and delivered blocks are held for the issuer. Issuers can cancel their own active quests at any board or with `/quests cancel <quest>`. Completed deliveries and rewards from cancelled or seven-day-expired quests are held durably and can be collected at any board or with `/quests claim`; inventory overflow drops at the claimant's feet. Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. They can instead run `/questadmin createboard physical` to generate a five-wide oak board above the targeted ground anchor, with glowing centered title and browsing-instruction signs above three decorative signs; its visible panel blocks and all five signs are registered. Right-clicking any registered board opens a compact native dashboard with dedicated browsing, creation, and claim screens. A player can request any inventory item material 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 items, rewards, issuers, and time remaining; custom reward names are shown together with their material type. Players can complete quests at any board or with `/quests complete <quest>` by delivering the required items. Exact escrowed rewards are granted immediately, and delivered items are held for the issuer. Issuers can cancel their own active quests at any board or with `/quests cancel <quest>`. Completed deliveries and rewards from cancelled or seven-day-expired quests are held durably and can be collected at any board or with `/quests claim`; inventory overflow drops at the claimant's feet.
## Requirements ## Requirements
@@ -23,7 +23,7 @@ The plugin JAR is written to `build/libs/`.
## Player commands ## Player commands
Player commands are disabled by default and can be enabled persistently by an administrator. When disabled, players within five blocks of a registered board can still use `/quests create <block> <quantity>` with material and quantity autocomplete; other player command forms remain disabled. Player commands are disabled by default and can be enabled persistently by an administrator. When disabled, players within five blocks of a registered board can still use `/quests create <item> <quantity>` with material and quantity autocomplete; other player command forms remain disabled.
```text ```text
/quests /quests
+3 -1
View File
@@ -7,7 +7,9 @@ okf_version: "0.1"
# Spigot Quest Board Knowledge # Spigot Quest Board Knowledge
This bundle specifies shared physical quest boards, reward escrow, block deliveries, item claims, player commands, administration, persistence, and plugin delivery requirements. This bundle specifies shared physical quest boards, reward escrow, item deliveries, item claims, player commands, administration, persistence, and plugin delivery requirements.
Login guidance is covered by [quest browsing](user-stories/us-004-browse-available-quests.md) and [recurring pending-item reminders](user-stories/us-007-expire-quests-and-claim-held-items.md).
## Explore ## Explore
+14
View File
@@ -6,6 +6,13 @@ description: Chronological record of material decisions affecting Spigot Quest B
# Spigot Quest Board Knowledge Log # Spigot Quest Board Knowledge Log
## 2026-09-10T01:38:21Z — Inventory-item quest requests
- Extended [US-003](user-stories/us-003-create-a-block-delivery-quest.md) and [US-005](user-stories/us-005-deliver-blocks-and-complete-a-quest.md) to support inventory item materials, including shulker shells, rather than only blocks.
- Validation and autocomplete use item eligibility and reject air and non-item materials; command, dialog, notification, and claim wording now describes items.
- Preserved the persistence format and existing escrow, delivery, and claim transitions.
- Confirmed catalog regressions failed before the fix using mocked server material properties, then verified shell creation, persisted settlement and claim acknowledgement, and existing regressions: all 134 tests and the plugin JAR passed `./gradlew clean check jar`.
## 2026-09-05 — Initial quest-board design ## 2026-09-05 — Initial quest-board design
- Administrators create physical quest boards, and every board exposes the same global quests and claims. - Administrators create physical quest boards, and every board exposes the same global quests and claims.
@@ -119,3 +126,10 @@ description: Chronological record of material decisions affecting Spigot Quest B
- Stored safe plain-text custom names alongside unchanged exact item metadata and inferred names from valid historical escrow data when possible. - Stored safe plain-text custom names alongside unchanged exact item metadata and inferred names from valid historical escrow data when possible.
- Kept malformed or unavailable metadata from blocking quest browsing by falling back to material descriptions. - Kept malformed or unavailable metadata from blocking quest browsing by falling back to material descriptions.
- Verified 128 tests and the plugin JAR with `./gradlew clean check jar`. - Verified 128 tests and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-07T01:31:27Z — Login quest guidance and recurring claim reminders
- Extended [US-004](user-stories/us-004-browse-available-quests.md) with a private active, unexpired quest count on every login and guidance to visit the quest board to accept or create a quest.
- Extended [US-007](user-stories/us-007-expire-quests-and-claim-held-items.md) with reminders on every login while actual pending claims remain, independently of notification acknowledgement and across reloads.
- Preserved existing real-time and durable notifications without changing claim storage or collection.
- Confirmed new behavior tests failed before implementation, then verified all 130 tests and the plugin JAR with `./gradlew clean check jar`.
+4 -4
View File
@@ -8,11 +8,11 @@ description: Catalog of user stories for the Spigot Quest Board plugin.
1. [US-001: Build and release the plugin](us-001-build-and-release-plugin.md) 1. [US-001: Build and release the plugin](us-001-build-and-release-plugin.md)
2. [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) 2. [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
3. [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md) 3. [US-003: Create an item-delivery quest](us-003-create-a-block-delivery-quest.md)
4. [US-004: Browse available quests](us-004-browse-available-quests.md) 4. [US-004: Browse available quests](us-004-browse-available-quests.md) — includes login quest counts and board guidance.
5. [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md) 5. [US-005: Deliver items and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md)
6. [US-006: Cancel an owned quest](us-006-cancel-an-owned-quest.md) 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) 7. [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md) — includes recurring login reminders for unclaimed items.
8. [US-008: Control player quest commands](us-008-control-player-quest-commands.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) 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) 10. [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.md)
@@ -23,5 +23,5 @@ As an **administrator**, I want to turn a targeted block into a quest board so t
## Related ## Related
- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md) - [US-003: Create an item-delivery quest](us-003-create-a-block-delivery-quest.md)
- [US-004: Browse available quests](us-004-browse-available-quests.md) - [US-004: Browse available quests](us-004-browse-available-quests.md)
@@ -1,17 +1,21 @@
--- ---
type: User Story type: User Story
title: "US-003: Create a block-delivery quest" title: "US-003: Create an item-delivery quest"
description: Let a player request block items while safely escrowing an inventory-funded reward. description: Let a player request inventory items while safely escrowing an inventory-funded reward.
status: done status: done
--- ---
# US-003: Create a block-delivery quest # US-003: Create an item-delivery quest
As a **player**, I want to request a block material and quantity in exchange for items I already own so that another player can fulfill my request safely. As a **player**, I want to request an item material and quantity in exchange for items I already own so that another player can fulfill my request safely.
## Acceptance criteria ## Acceptance criteria
- [x] A player can create a quest through a registered quest board by selecting a valid block material, a positive quantity, and reward items from their inventory. - [x] Requests accept all inventory item materials, including shulker shells and existing block items, while rejecting air, invalid names, and non-item materials.
- [x] Contextual material autocomplete includes non-block items; command and dialog wording describes items rather than only blocks.
- [x] Regression tests verify shell creation, autocomplete, and existing block-item support.
- [x] A player can create a quest through a registered quest board by selecting a valid item material, a positive quantity, and reward items from their inventory.
- [x] Reward items must exist in the issuer's inventory and are removed only when quest creation succeeds. - [x] Reward items must exist in the issuer's inventory and are removed only when quest creation succeeds.
- [x] Removed rewards are held durably in escrow by the quest system. - [x] Removed rewards are held durably in escrow by the quest system.
- [x] A created quest records a unique identifier, issuer UUID and current name, requested material and quantity, exact reward items, creation time, and expiration time. - [x] A created quest records a unique identifier, issuer UUID and current name, requested material and quantity, exact reward items, creation time, and expiration time.
@@ -24,5 +28,5 @@ As a **player**, I want to request a block material and quantity in exchange for
## Related ## Related
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) - [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
- [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md) - [US-005: Deliver items 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) - [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
@@ -19,8 +19,11 @@ As a **player**, I want to browse current quests so that I can decide which bloc
- [x] Listing and autocomplete do not expose stale quest identifiers as completable or cancellable. - [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. - [x] Automated tests verify filtering, displayed fields, duration boundaries, and command aliases.
- [x] Every login privately reports the global active, unexpired quest count with natural zero and singular wording, followed by “Visit the quest board to accept or create a quest.”
- [x] Automated tests verify repeated login summaries, count wording, and exclusion of completed, cancelled, and expired quests.
## Related ## Related
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) - [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
- [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md) - [US-005: Deliver items and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md)
- [US-013: Show custom reward item names](us-013-show-custom-reward-item-names.md) - [US-013: Show custom reward item names](us-013-show-custom-reward-item-names.md)
@@ -1,29 +1,32 @@
--- ---
type: User Story type: User Story
title: "US-005: Deliver blocks and complete a quest" title: "US-005: Deliver items and complete a quest"
description: Let a player deliver requested blocks at a quest board and receive the escrowed reward. description: Let a player deliver requested items at a quest board and receive the escrowed reward.
status: done status: done
--- ---
# US-005: Deliver blocks and complete a quest # US-005: Deliver items and complete a quest
As a **player**, I want to deliver requested blocks to a quest board so that I receive the promised reward and the issuer can claim my delivery. As a **player**, I want to deliver requested items to a quest board so that I receive the promised reward and the issuer can claim my delivery.
## Acceptance criteria ## Acceptance criteria
- [x] Non-block requested items, including shulker shells, can be delivered and held for issuer claiming using existing escrow and rollback protections.
- [x] Delivery notifications and claim messages refer to items, and regression tests verify shell settlement and existing block deliveries.
- [x] An active quest offers a Complete Quest action through every registered board. - [x] An active quest offers a Complete Quest action through every registered board.
- [x] Completion requires the delivering player to possess the full requested quantity of the exact block material. - [x] Completion requires the delivering player to possess the full requested quantity of the exact item material.
- [x] Successful completion removes the requested blocks from the delivering player's inventory and stores them as a claim for the issuer. - [x] Successful completion removes the requested items from the delivering player's inventory and stores them as a claim for the issuer.
- [x] Successful completion gives the exact escrowed reward items to the delivering player. - [x] Successful completion gives the exact escrowed reward items to the delivering player.
- [x] Reward items that do not fit are dropped safely at the delivering player's feet with a clear message. - [x] Reward items that do not fit are dropped safely at the delivering player's feet with a clear message.
- [x] The issuer is notified immediately when online or on their next login when offline that delivered blocks can be claimed at a quest board. - [x] The issuer is notified immediately when online or on their next login when offline that delivered items can be claimed at a quest board.
- [x] When player commands are enabled, `/quests complete <quest>` provides equivalent behavior with contextual autocomplete. - [x] When player commands are enabled, `/quests complete <quest>` provides equivalent behavior with contextual autocomplete.
- [x] Completion is atomic and exactly one player can complete a quest, including simultaneous attempts. - [x] Completion is atomic and exactly one player can complete a quest, including simultaneous attempts.
- [x] Validation or persistence failure does not consume delivered blocks, release rewards, or change quest state. - [x] Validation or persistence failure does not consume delivered items, release rewards, or change quest state.
- [x] Completed quests are no longer shown as active or completable. - [x] Completed quests are no longer shown as active or completable.
- [x] Automated tests verify inventory validation, settlement, overflow, notifications, rollback, and concurrent completion protection. - [x] Automated tests verify inventory validation, settlement, overflow, notifications, rollback, and concurrent completion protection.
## Related ## Related
- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md) - [US-003: Create an item-delivery quest](us-003-create-a-block-delivery-quest.md)
- [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md) - [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
@@ -24,7 +24,11 @@ As a **quest issuer**, I want delivered blocks and returned rewards held at the
- [x] Pending claims and notification state survive logout and server restart without duplication or loss. - [x] Pending claims and notification state survive logout and server restart without duplication or loss.
- [x] Automated tests verify expiry boundaries, each claim source, notifications, overflow, failure recovery, and persistence. - [x] Automated tests verify expiry boundaries, each claim source, notifications, overflow, failure recovery, and persistence.
- [x] Every login with pending claims shows “You have items waiting to be claimed. Visit the quest board to collect them.” independently of previous notification delivery.
- [x] Reminders stop once all items are claimed, preserve real-time notifications, and do not change claims or item storage.
- [x] Automated tests verify recurring reminders across logins and reloads, isolation by player, and silence after collection.
## Related ## Related
- [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md) - [US-005: Deliver items and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md)
- [US-006: Cancel an owned quest](us-006-cancel-an-owned-quest.md) - [US-006: Cancel an owned quest](us-006-cancel-an-owned-quest.md)
@@ -23,6 +23,6 @@ As an **administrator**, I want to enable or disable player quest commands so th
## Related ## Related
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) - [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-003: Create an item-delivery quest](us-003-create-a-block-delivery-quest.md)
- [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md) - [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
- [US-012: Create quests by command near a board](us-012-create-quests-near-a-board.md) - [US-012: Create quests by command near a board](us-012-create-quests-near-a-board.md)
@@ -24,7 +24,7 @@ As a **player**, I want the quest-board interface to fit on screen so that I can
## Related ## Related
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) - [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-003: Create an item-delivery quest](us-003-create-a-block-delivery-quest.md)
- [US-004: Browse available quests](us-004-browse-available-quests.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-005: Deliver items 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) - [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
@@ -11,7 +11,7 @@ As a **player**, I want to use the quest-creation command near a quest board so
## Acceptance criteria ## Acceptance criteria
- [x] When global player quest commands are disabled, `/quests create <block> <quantity>` remains available to a player within five blocks of a registered board interaction location. - [x] When global player quest commands are disabled, `/quests create <item> <quantity>` remains available to a player within five blocks of a registered board interaction location.
- [x] Material and quantity autocomplete remain available for nearby quest creation. - [x] Material and quantity autocomplete remain available for nearby quest creation.
- [x] A player farther than five blocks from every registered board is directed to move closer and no quest or escrow change occurs. - [x] A player farther than five blocks from every registered board is directed to move closer and no quest or escrow change occurs.
- [x] Board proximity requires the player and registered location to be in the same world. - [x] Board proximity requires the player and registered location to be in the same world.
@@ -22,5 +22,5 @@ As a **player**, I want named reward items identified by both custom name and ma
## Related ## Related
- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md) - [US-003: Create an item-delivery quest](us-003-create-a-block-delivery-quest.md)
- [US-004: Browse available quests](us-004-browse-available-quests.md) - [US-004: Browse available quests](us-004-browse-available-quests.md)
@@ -1,6 +1,7 @@
package games.dmg.spigotquestboard; package games.dmg.spigotquestboard;
import java.io.IOException; import java.io.IOException;
import java.time.Clock;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.logging.Level; import java.util.logging.Level;
@@ -15,11 +16,17 @@ final class BukkitIssuerNotifier implements IssuerNotifier, Listener {
private final QuestService quests; private final QuestService quests;
private final Server server; private final Server server;
private final Logger logger; private final Logger logger;
private final Clock clock;
BukkitIssuerNotifier(QuestService quests, Server server, Logger logger) { BukkitIssuerNotifier(QuestService quests, Server server, Logger logger) {
this(quests, server, logger, Clock.systemUTC());
}
BukkitIssuerNotifier(QuestService quests, Server server, Logger logger, Clock clock) {
this.quests = Objects.requireNonNull(quests, "quests"); this.quests = Objects.requireNonNull(quests, "quests");
this.server = Objects.requireNonNull(server, "server"); this.server = Objects.requireNonNull(server, "server");
this.logger = Objects.requireNonNull(logger, "logger"); this.logger = Objects.requireNonNull(logger, "logger");
this.clock = Objects.requireNonNull(clock, "clock");
} }
@Override @Override
@@ -32,7 +39,20 @@ final class BukkitIssuerNotifier implements IssuerNotifier, Listener {
@EventHandler @EventHandler
public void onPlayerJoin(PlayerJoinEvent event) { public void onPlayerJoin(PlayerJoinEvent event) {
deliver(event.getPlayer()); Player player = event.getPlayer();
int count = quests.activeQuests(clock.instant()).size();
String summary = switch (count) {
case 0 -> "No quests are currently available.";
case 1 -> "1 quest is currently available.";
default -> count + " quests are currently available.";
};
player.sendMessage(summary + " Visit the quest board to accept or create a quest.");
if (!quests.claimsFor(player.getUniqueId()).isEmpty()) {
player.sendMessage(
"You have items waiting to be claimed. Visit the quest board to collect them."
);
}
deliver(player);
} }
void deliver(Player player) { void deliver(Player player) {
@@ -6,11 +6,11 @@ import java.util.Locale;
import java.util.Optional; import java.util.Optional;
import org.bukkit.Material; import org.bukkit.Material;
final class BukkitBlockMaterialCatalog implements BlockMaterialCatalog { final class BukkitItemMaterialCatalog implements ItemMaterialCatalog {
@Override @Override
public Optional<String> normalizeBlock(String input) { public Optional<String> normalizeItem(String input) {
Material material = Material.matchMaterial(input == null ? "" : input); Material material = Material.matchMaterial(input == null ? "" : input);
if (material == null || material.isAir() || !material.isBlock()) { if (material == null || material.isAir() || !material.isItem()) {
return Optional.empty(); return Optional.empty();
} }
return Optional.of(material.name()); return Optional.of(material.name());
@@ -20,7 +20,7 @@ final class BukkitBlockMaterialCatalog implements BlockMaterialCatalog {
public List<String> suggest(String prefix) { public List<String> suggest(String prefix) {
String normalizedPrefix = prefix.toUpperCase(Locale.ROOT); String normalizedPrefix = prefix.toUpperCase(Locale.ROOT);
return Arrays.stream(Material.values()) return Arrays.stream(Material.values())
.filter(material -> material.isBlock() && !material.isAir()) .filter(material -> material.isItem() && !material.isAir())
.map(Material::name) .map(Material::name)
.filter(name -> name.startsWith(normalizedPrefix)) .filter(name -> name.startsWith(normalizedPrefix))
.sorted() .sorted()
@@ -3,7 +3,7 @@ package games.dmg.spigotquestboard;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
interface BlockMaterialCatalog { interface ItemMaterialCatalog {
Optional<String> normalizeBlock(String input); Optional<String> normalizeItem(String input);
List<String> suggest(String prefix); List<String> suggest(String prefix);
} }
@@ -113,7 +113,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
+ "metadata, is escrowed only if the quest saves successfully.", + "metadata, is escrowed only if the quest saves successfully.",
List.of(action("Create quest", QuestBoardDialogSpec.Route.SUBMIT_CREATE)), List.of(action("Create quest", QuestBoardDialogSpec.Route.SUBMIT_CREATE)),
List.of( List.of(
new QuestBoardDialogSpec.Input("requested_material", "Requested block", "", 64), new QuestBoardDialogSpec.Input("requested_material", "Requested item", "", 64),
new QuestBoardDialogSpec.Input("requested_quantity", "Quantity", "64", 10) new QuestBoardDialogSpec.Input("requested_quantity", "Quantity", "64", 10)
), ),
true true
@@ -221,7 +221,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
} }
ActionButton collect = callbackButton( ActionButton collect = callbackButton(
"Collect pending claims", "Collect pending claims",
"Collect delivered blocks and returned rewards", "Collect delivered items and returned rewards",
250, 250,
respondingPlayer -> submitClaim(respondingPlayer) respondingPlayer -> submitClaim(respondingPlayer)
); );
@@ -247,7 +247,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
private ActionButton renderQuestAction(QuestBoardDialogSpec.Action action) { private ActionButton renderQuestAction(QuestBoardDialogSpec.Action action) {
return switch (action.route()) { return switch (action.route()) {
case COMPLETE -> callbackButton( case COMPLETE -> callbackButton(
action.label(), "Deliver the requested blocks", 180, action.label(), "Deliver the requested items", 180,
player -> submitCompletion(player, action.questId()) player -> submitCompletion(player, action.questId())
); );
case CANCEL -> callbackButton( case CANCEL -> callbackButton(
@@ -328,7 +328,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
} }
return claims.stream().map(claim -> { return claims.stream().map(claim -> {
String kind = claim.type() == QuestClaimType.DELIVERED_BLOCKS String kind = claim.type() == QuestClaimType.DELIVERED_BLOCKS
? "DELIVERED BLOCKS" : "RETURNED REWARD (" + claim.source().name() + ")"; ? "DELIVERED ITEMS" : "RETURNED REWARD (" + claim.source().name() + ")";
String items = claim.items().stream() String items = claim.items().stream()
.map(item -> item.amount() + " " + item.material()) .map(item -> item.amount() + " " + item.material())
.reduce((left, right) -> left + ", " + right) .reduce((left, right) -> left + ", " + right)
@@ -382,7 +382,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
player.sendMessage(exception.getMessage()); player.sendMessage(exception.getMessage());
} catch (IOException exception) { } catch (IOException exception) {
player.sendMessage( player.sendMessage(
"The quest could not be saved. Your delivered blocks were restored." "The quest could not be saved. Your delivered items were restored."
); );
} }
} }
@@ -55,7 +55,7 @@ final class QuestClaimController implements QuestClaimGateway {
private static String description(QuestClaim claim) { private static String description(QuestClaim claim) {
if (claim.type() == QuestClaimType.DELIVERED_BLOCKS) { if (claim.type() == QuestClaimType.DELIVERED_BLOCKS) {
return "Collected delivered blocks from completed quest " + claim.questId() + "."; return "Collected delivered items from completed quest " + claim.questId() + ".";
} }
String reason = claim.source() == QuestClaimSource.EXPIRATION String reason = claim.source() == QuestClaimSource.EXPIRATION
? "expired" : "cancelled"; ? "expired" : "cancelled";
@@ -148,7 +148,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(exception.getMessage()); sender.sendMessage(exception.getMessage());
} catch (IOException exception) { } catch (IOException exception) {
sender.sendMessage( sender.sendMessage(
"The quest could not be saved. Your delivered blocks were restored." "The quest could not be saved. Your delivered items were restored."
); );
} }
return true; return true;
@@ -223,7 +223,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
); );
} }
if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) { if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) {
return creator.suggestBlockMaterials(arguments[1]); return creator.suggestItemMaterials(arguments[1]);
} }
if (arguments.length == 3 && "create".equalsIgnoreCase(arguments[0])) { if (arguments.length == 3 && "create".equalsIgnoreCase(arguments[0])) {
return startsWith(QUANTITIES, arguments[2]); return startsWith(QUANTITIES, arguments[2]);
@@ -267,7 +267,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
private static void usage(CommandSender sender) { private static void usage(CommandSender sender) {
sender.sendMessage( sender.sendMessage(
"Usage: /quests [list] | /quests create <block> <quantity> | " "Usage: /quests [list] | /quests create <item> <quantity> | "
+ "/quests complete <quest> | /quests cancel <quest> | /quests claim" + "/quests complete <quest> | /quests cancel <quest> | /quests claim"
); );
sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed."); sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed.");
@@ -8,12 +8,12 @@ import org.bukkit.entity.Player;
final class QuestCreationController implements QuestCreationGateway { final class QuestCreationController implements QuestCreationGateway {
private final QuestService quests; private final QuestService quests;
private final BlockMaterialCatalog materials; private final ItemMaterialCatalog materials;
private final HeldRewardInventory rewards; private final HeldRewardInventory rewards;
QuestCreationController( QuestCreationController(
QuestService quests, QuestService quests,
BlockMaterialCatalog materials, ItemMaterialCatalog materials,
HeldRewardInventory rewards HeldRewardInventory rewards
) { ) {
this.quests = Objects.requireNonNull(quests, "quests"); this.quests = Objects.requireNonNull(quests, "quests");
@@ -29,9 +29,9 @@ final class QuestCreationController implements QuestCreationGateway {
if (requestedAmount <= 0) { if (requestedAmount <= 0) {
throw new IllegalArgumentException("Requested quantity must be positive"); throw new IllegalArgumentException("Requested quantity must be positive");
} }
String material = materials.normalizeBlock(requestedMaterial) String material = materials.normalizeItem(requestedMaterial)
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
"Requested material must be a valid block" "Requested material must be a valid item"
)); ));
HeldRewardInventory.RemovedReward removed = rewards.remove(player); HeldRewardInventory.RemovedReward removed = rewards.remove(player);
try { try {
@@ -46,7 +46,7 @@ final class QuestCreationController implements QuestCreationGateway {
} }
@Override @Override
public List<String> suggestBlockMaterials(String prefix) { public List<String> suggestItemMaterials(String prefix) {
return materials.suggest(prefix == null ? "" : prefix); return materials.suggest(prefix == null ? "" : prefix);
} }
} }
@@ -9,5 +9,5 @@ interface QuestCreationGateway {
Quest create(Player player, String requestedMaterial, int requestedAmount, Instant createdAt) Quest create(Player player, String requestedMaterial, int requestedAmount, Instant createdAt)
throws IOException; throws IOException;
List<String> suggestBlockMaterials(String prefix); List<String> suggestItemMaterials(String prefix);
} }
@@ -33,7 +33,7 @@ final class QuestService implements QuestBrowser {
String material = Objects.requireNonNull(requestedMaterial, "requestedMaterial") String material = Objects.requireNonNull(requestedMaterial, "requestedMaterial")
.trim().toUpperCase(Locale.ROOT); .trim().toUpperCase(Locale.ROOT);
if (material.isEmpty() || "AIR".equals(material)) { if (material.isEmpty() || "AIR".equals(material)) {
throw new IllegalArgumentException("Requested material must be a block"); throw new IllegalArgumentException("Requested material must be a valid item");
} }
Quest quest = new Quest( Quest quest = new Quest(
UUID.randomUUID(), issuerId, issuerName, material, requestedAmount, UUID.randomUUID(), issuerId, issuerName, material, requestedAmount,
@@ -90,7 +90,7 @@ final class QuestService implements QuestBrowser {
IssuerNotification notification = new IssuerNotification( IssuerNotification notification = new IssuerNotification(
UUID.randomUUID(), active.id(), active.issuerId(), UUID.randomUUID(), active.id(), active.issuerId(),
"Quest " + active.id() + " was completed. Your delivered " "Quest " + active.id() + " was completed. Your delivered "
+ active.requestedMaterial() + " blocks can be claimed at a quest board.", + active.requestedMaterial() + " items can be claimed at a quest board.",
completedAt completedAt
); );
@@ -37,11 +37,11 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
} }
QuestCreationGateway creator = new QuestCreationController( QuestCreationGateway creator = new QuestCreationController(
quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory() quests, new BukkitItemMaterialCatalog(), new BukkitHeldRewardInventory()
); );
Clock clock = Clock.systemUTC(); Clock clock = Clock.systemUTC();
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier( BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
quests, getServer(), getLogger() quests, getServer(), getLogger(), clock
); );
QuestCompletionGateway completer = new QuestCompletionController( QuestCompletionGateway completer = new QuestCompletionController(
quests, new BukkitQuestCompletionInventory(), notifier quests, new BukkitQuestCompletionInventory(), notifier
@@ -4,15 +4,21 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.logging.Logger; import java.util.logging.Logger;
import org.bukkit.Server; import org.bukkit.Server;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class BukkitIssuerNotifierTest { final class BukkitIssuerNotifierTest {
@@ -82,6 +88,90 @@ final class BukkitIssuerNotifierTest {
assertTrue(service.state().notifications().isEmpty()); assertTrue(service.state().notifications().isEmpty());
} }
@Test
void everyLoginReportsAvailableQuestsWithNaturalWordingAndBoardHint() throws Exception {
QuestService service = new QuestService(new Repository());
Instant now = Instant.EPOCH.plusSeconds(604800);
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
service, mock(Server.class), Logger.getAnonymousLogger(),
Clock.fixed(now, ZoneOffset.UTC)
);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(player);
String hint = " Visit the quest board to accept or create a quest.";
// Expired at exactly login time, even before the expiry task runs.
createQuest(service, Instant.EPOCH);
Quest completed = createQuest(service, now);
service.complete(completed.id(), new EscrowItem("STONE", 1, null), now);
Quest cancelled = createQuest(service, now);
service.cancel(cancelled.id(), cancelled.issuerId(), now);
notifier.onPlayerJoin(event);
verify(player).sendMessage("No quests are currently available." + hint);
createQuest(service, now);
notifier.onPlayerJoin(event);
verify(player).sendMessage("1 quest is currently available." + hint);
createQuest(service, now);
notifier.onPlayerJoin(event);
notifier.onPlayerJoin(event);
verify(player, times(2)).sendMessage("2 quests are currently available." + hint);
verify(player, never()).sendMessage(contains("items waiting"));
}
@Test
void claimsAreRemindedOnEveryLoginAcrossReloadsUntilAllCollected() throws Exception {
Repository repository = new Repository();
QuestService service = completedService(repository);
UUID issuer = service.state().notifications().values().iterator().next().recipientId();
Quest cancelled = service.create(
issuer, "Issuer", "STONE", 1,
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
);
service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1));
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(issuer);
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(player);
String reminder = "You have items waiting to be claimed. "
+ "Visit the quest board to collect them.";
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
service, mock(Server.class), Logger.getAnonymousLogger()
);
notifier.onPlayerJoin(event);
notifier.onPlayerJoin(event);
verify(player, times(2)).sendMessage(reminder);
assertTrue(service.pendingNotifications(issuer).isEmpty());
assertEquals(2, service.claimsFor(issuer).size());
service = new QuestService(repository);
notifier = new BukkitIssuerNotifier(service, mock(Server.class), Logger.getAnonymousLogger());
notifier.onPlayerJoin(event);
verify(player, times(3)).sendMessage(reminder);
service.acknowledgeClaim(issuer, service.claimsFor(issuer).getFirst().id());
notifier.onPlayerJoin(event);
verify(player, times(4)).sendMessage(reminder);
clearInvocations(player);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
notifier.onPlayerJoin(event);
verify(player, never()).sendMessage(reminder);
when(player.getUniqueId()).thenReturn(issuer);
service.acknowledgeClaim(issuer, service.claimsFor(issuer).getFirst().id());
notifier.onPlayerJoin(event);
verify(player, never()).sendMessage(reminder);
}
private static Quest createQuest(QuestService service, Instant now) throws Exception {
return service.create(
UUID.randomUUID(), "Issuer", "STONE", 1,
List.of(new EscrowItem("DIAMOND", 1, null)), now
);
}
private static QuestService completedService(Repository repository) throws Exception { private static QuestService completedService(Repository repository) throws Exception {
QuestService service = new QuestService(repository); QuestService service = new QuestService(repository);
Quest quest = service.create( Quest quest = service.create(
@@ -0,0 +1,100 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Optional;
import org.bukkit.Material;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
final class BukkitItemMaterialCatalogTest {
private final BukkitItemMaterialCatalog catalog = new BukkitItemMaterialCatalog();
private MockedStatic<Material> materials;
@BeforeEach
void provideServerMaterialProperties() {
// Purpur delegates material properties to server-only registries.
String[] names = {"SHULKER_SHELL", "SHULKER_BOX", "STONE", "AIR", "CAVE_AIR", "VOID_AIR", "WATER", "FIRE"};
Material[] fixtures = new Material[names.length];
for (int i = 0; i < names.length; i++) {
Material material = Mockito.mock(Material.class);
Mockito.when(material.name()).thenReturn(names[i]);
Mockito.when(material.isItem()).thenReturn(i <= 3);
Mockito.when(material.isBlock()).thenReturn(i != 0);
Mockito.when(material.isAir()).thenReturn(i >= 3 && i <= 5);
fixtures[i] = material;
}
materials = Mockito.mockStatic(Material.class);
materials.when(Material::values).thenReturn(fixtures);
for (int i = 0; i < names.length; i++) {
String name = names[i];
Material material = fixtures[i];
materials.when(() -> Material.matchMaterial(name)).thenReturn(material);
materials.when(() -> Material.matchMaterial(name.toLowerCase(java.util.Locale.ROOT)))
.thenReturn(material);
}
}
@AfterEach
void closeMaterialFixtures() {
materials.close();
}
@Test
void acceptsShellsAndExistingBlockItems() {
assertEquals(Optional.of("SHULKER_SHELL"), catalog.normalizeItem("shulker_shell"));
assertEquals(Optional.of("SHULKER_BOX"), catalog.normalizeItem("shulker_box"));
assertEquals(Optional.of("STONE"), catalog.normalizeItem("stone"));
}
@Test
void rejectsAirInvalidAndNonItemMaterials() {
for (String input : new String[] {"AIR", "CAVE_AIR", "VOID_AIR", "WATER", "FIRE", "invalid", ""}) {
assertTrue(catalog.normalizeItem(input).isEmpty(), input);
}
assertTrue(catalog.normalizeItem(null).isEmpty());
}
@Test
void createsPersistsAndSettlesShellRequest(@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory)
throws Exception {
QuestRepository repository = new YamlQuestRepository(directory.resolve("quests.yml"));
QuestService service = new QuestService(repository);
org.bukkit.entity.Player player = Mockito.mock(org.bukkit.entity.Player.class);
java.util.UUID issuer = java.util.UUID.randomUUID();
Mockito.when(player.getUniqueId()).thenReturn(issuer);
Mockito.when(player.getName()).thenReturn("Issuer");
EscrowItem reward = new EscrowItem("DIAMOND", 1, null);
HeldRewardInventory inventory = ignored -> new HeldRewardInventory.RemovedReward() {
@Override public EscrowItem item() { return reward; }
@Override public void rollback() { throw new AssertionError("Unexpected rollback"); }
};
QuestCreationController controller = new QuestCreationController(service, catalog, inventory);
assertTrue(controller.suggestItemMaterials("shulker_").contains("SHULKER_SHELL"));
Quest quest = controller.create(player, "shulker_shell", 16, java.time.Instant.EPOCH);
service = new QuestService(repository);
EscrowItem delivery = new EscrowItem("SHULKER_SHELL", 16, null);
QuestCompletion completion = service.complete(
quest.id(), delivery, java.time.Instant.EPOCH.plusSeconds(1)
);
assertEquals(java.util.List.of(reward), completion.reward());
service = new QuestService(repository);
QuestClaim claim = service.claimsFor(issuer).getFirst();
assertEquals(java.util.List.of(delivery), claim.items());
assertEquals(QuestStatus.COMPLETED, service.state().quests().get(quest.id()).status());
service.acknowledgeClaim(issuer, claim.id());
assertTrue(new QuestService(repository).claimsFor(issuer).isEmpty());
}
@Test
void suggestsShellsAndBoxesButNotNonItems() {
assertTrue(catalog.suggest("shulker_").contains("SHULKER_SHELL"));
assertTrue(catalog.suggest("shulker_").contains("SHULKER_BOX"));
assertTrue(catalog.suggest("WATER").stream().noneMatch("WATER"::equals));
}
}
@@ -267,7 +267,7 @@ final class QuestBoardDialogUiTest {
} }
@Test @Test
void boardListingDistinguishesDeliveredBlocksFromReturnedRewards() { void boardListingDistinguishesDeliveredItemsFromReturnedRewards() {
RecordingCreator creator = new RecordingCreator(false); RecordingCreator creator = new RecordingCreator(false);
Player player = mock(Player.class); Player player = mock(Player.class);
UUID owner = UUID.randomUUID(); UUID owner = UUID.randomUUID();
@@ -301,7 +301,7 @@ final class QuestBoardDialogUiTest {
String listing = ui.claimListingText(player); String listing = ui.claimListingText(player);
org.junit.jupiter.api.Assertions.assertTrue(listing.contains("DELIVERED BLOCKS — 2 STONE")); org.junit.jupiter.api.Assertions.assertTrue(listing.contains("DELIVERED ITEMS — 2 STONE"));
org.junit.jupiter.api.Assertions.assertTrue( org.junit.jupiter.api.Assertions.assertTrue(
listing.contains("RETURNED REWARD (EXPIRATION) — 1 DIAMOND") listing.contains("RETURNED REWARD (EXPIRATION) — 1 DIAMOND")
); );
@@ -412,7 +412,7 @@ final class QuestBoardDialogUiTest {
} }
@Override @Override
public List<String> suggestBlockMaterials(String prefix) { public List<String> suggestItemMaterials(String prefix) {
return List.of(); return List.of();
} }
} }
@@ -29,7 +29,7 @@ final class QuestClaimControllerTest {
assertEquals(new ClaimCollectionResult(3, 6), result); assertEquals(new ClaimCollectionResult(3, 6), result);
assertTrue(service.claimsFor(issuer).isEmpty()); assertTrue(service.claimsFor(issuer).isEmpty());
verify(player).sendMessage(contains("delivered blocks from completed quest")); verify(player).sendMessage(contains("delivered items from completed quest"));
verify(player).sendMessage(contains("returned reward from cancelled quest")); verify(player).sendMessage(contains("returned reward from cancelled quest"));
verify(player).sendMessage(contains("returned reward from expired quest")); verify(player).sendMessage(contains("returned reward from expired quest"));
verify(player).sendMessage(contains("dropped at your feet, protected for you")); verify(player).sendMessage(contains("dropped at your feet, protected for you"));
@@ -417,7 +417,7 @@ final class QuestCommandTest {
} }
@Override @Override
public List<String> suggestBlockMaterials(String prefix) { public List<String> suggestItemMaterials(String prefix) {
suggestionPrefix = prefix; suggestionPrefix = prefix;
return List.of("STONE", "STONE_BRICKS"); return List.of("STONE", "STONE_BRICKS");
} }
@@ -71,9 +71,9 @@ final class QuestCreationControllerTest {
private static QuestCreationController controller( private static QuestCreationController controller(
MemoryQuestRepository repository, HeldRewardInventory inventory MemoryQuestRepository repository, HeldRewardInventory inventory
) throws IOException { ) throws IOException {
BlockMaterialCatalog catalog = new BlockMaterialCatalog() { ItemMaterialCatalog catalog = new ItemMaterialCatalog() {
@Override @Override
public Optional<String> normalizeBlock(String input) { public Optional<String> normalizeItem(String input) {
return "stone".equalsIgnoreCase(input) ? Optional.of("STONE") : Optional.empty(); return "stone".equalsIgnoreCase(input) ? Optional.of("STONE") : Optional.empty();
} }