4 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
dmg 02f74d1822 feat(quests): show custom reward item names
Release / release (push) Successful in 2m37s
CI / build (push) Successful in 1m11s
2026-09-05 11:39:01 -04:00
dmg e12e31c0ad feat(quests): allow nearby command creation
Release / release (push) Successful in 2m50s
CI / build (push) Successful in 1m8s
2026-09-05 10:03:30 -04:00
40 changed files with 939 additions and 79 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
# 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).
## 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. 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
@@ -23,7 +23,7 @@ The plugin JAR is written to `build/libs/`.
## Player commands
Player commands are disabled by default and can be enabled persistently by an administrator. When disabled, players are directed to use a physical quest board.
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
/quests
+3 -1
View File
@@ -7,7 +7,9 @@ okf_version: "0.1"
# 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
+28
View File
@@ -6,6 +6,13 @@ description: Chronological record of material decisions affecting Spigot Quest B
# 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
- Administrators create physical quest boards, and every board exposes the same global quests and claims.
@@ -105,3 +112,24 @@ description: Chronological record of material decisions affecting Spigot Quest B
- Added exact full-structure detection to refresh existing generated boards once without modifying custom or altered signs.
- Added two-sign snapshot rollback when a refresh cannot complete safely.
- Verified 116 tests and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-05 — Nearby command-based quest creation
- Allowed `/quests create <block> <quantity>` and its material and quantity autocomplete within five blocks of any registered board location even while global player commands are disabled.
- Kept listing, completion, cancellation, and claiming commands disabled under that setting while preserving global command behavior when enabled.
- Enforced same-world Euclidean proximity with an inclusive five-block boundary for custom and generated boards.
- Verified 121 tests and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-05 — Named reward identification
- Displayed custom reward names together with their material type in shared board details and command listings, such as `1 × Shopping List (PAPER)`.
- 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.
- 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`.
+6 -4
View File
@@ -8,12 +8,14 @@ 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)
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)
4. [US-004: Browse available quests](us-004-browse-available-quests.md)
5. [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-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) — includes login quest counts and board guidance.
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)
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)
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)
11. [US-011: Add readable physical-board signage](us-011-add-readable-physical-board-signage.md)
12. [US-012: Create quests by command near a board](us-012-create-quests-near-a-board.md)
13. [US-013: Show custom reward item names](us-013-show-custom-reward-item-names.md)
@@ -23,5 +23,5 @@ As an **administrator**, I want to turn a targeted block into a quest board so t
## 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)
@@ -1,17 +1,21 @@
---
type: User Story
title: "US-003: Create a block-delivery quest"
description: Let a player request block items while safely escrowing an inventory-funded reward.
title: "US-003: Create an item-delivery quest"
description: Let a player request inventory items while safely escrowing an inventory-funded reward.
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
- [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] 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.
@@ -24,5 +28,5 @@ As a **player**, I want to request a block material and quantity in exchange for
## Related
- [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)
@@ -19,7 +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] 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
- [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)
@@ -1,29 +1,32 @@
---
type: User Story
title: "US-005: Deliver blocks and complete a quest"
description: Let a player deliver requested blocks at a quest board and receive the escrowed reward.
title: "US-005: Deliver items and complete a quest"
description: Let a player deliver requested items at a quest board and receive the escrowed reward.
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
- [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] Completion requires the delivering player to possess the full requested quantity of the exact block material.
- [x] Successful completion removes the requested blocks from the delivering player's inventory and stores them as a claim for the issuer.
- [x] Completion requires the delivering player to possess the full requested quantity of the exact item material.
- [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] 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] 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] Automated tests verify inventory validation, settlement, overflow, notifications, rollback, and concurrent completion protection.
## 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)
@@ -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] 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
- [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)
@@ -14,7 +14,7 @@ As an **administrator**, I want to enable or disable player quest commands so th
- [x] Player `/quests` commands are disabled by default.
- [x] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete.
- [x] The command setting persists across server restarts.
- [x] When disabled, `/quests`, `list`, `create`, `complete`, `cancel`, and `claim` reject execution with a clear message directing the player to a quest board.
- [x] When disabled, `/quests`, `list`, `complete`, `cancel`, and `claim` reject execution with a clear message directing the player to a quest board; nearby creation follows [US-012](us-012-create-quests-near-a-board.md).
- [x] Disabling commands does not prevent any equivalent action through a registered board.
- [x] `/questadmin` remains available to authorized administrators regardless of the player-command setting.
- [x] Unauthorized users cannot change the setting.
@@ -23,5 +23,6 @@ As an **administrator**, I want to enable or disable player quest commands so th
## 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-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-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
- [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-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)
@@ -0,0 +1,29 @@
---
type: User Story
title: "US-012: Create quests by command near a board"
description: Allow command-based quest creation and material autocomplete near a physical board even when global player commands are disabled.
status: done
---
# US-012: Create quests by command near a board
As a **player**, I want to use the quest-creation command near a quest board so that I can use material autocomplete while still interacting at the physical board.
## Acceptance criteria
- [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] 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] A distance of exactly five blocks is accepted and a greater distance is rejected.
- [x] Root listing, `list`, `complete`, `cancel`, and `claim` remain disabled while global player commands are disabled.
- [x] When global player quest commands are enabled, all command behavior, including creation, remains available regardless of board proximity.
- [x] Custom single-block boards and every registered interaction location on generated boards satisfy the proximity requirement.
- [x] Board-dialog creation remains available regardless of command settings or command proximity rules.
- [x] Automated tests verify boundaries, cross-world behavior, autocomplete, command settings, disabled subcommands, and generated-board proximity.
## 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-008: Control player quest commands](us-008-control-player-quest-commands.md)
@@ -0,0 +1,26 @@
---
type: User Story
title: "US-013: Show custom reward item names"
description: Display a reward item's custom name together with its material type when browsing quests.
status: done
---
# US-013: Show custom reward item names
As a **player**, I want named reward items identified by both custom name and material so that I understand exactly what a quest will award.
## Acceptance criteria
- [x] A reward with a custom display name is shown as amount, readable custom name, and material type, such as `1 × Shopping List (PAPER)`.
- [x] An unnamed reward continues to show its amount and material type, such as `3 × DIAMOND`.
- [x] Styled custom names are converted to safe readable plain text without changing the escrowed item's exact metadata.
- [x] Quest-board detail dialogs and `/quests` listings use the same reward representation.
- [x] Newly escrowed rewards persist the readable custom name alongside their exact serialized item data.
- [x] Existing persisted named rewards derive their custom name from valid stored item metadata when possible.
- [x] Missing, malformed, or unreadable item metadata safely falls back to the material type without preventing quest browsing.
- [x] Automated tests verify named, unnamed, styled, persisted, historical, and malformed reward metadata.
## Related
- [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)
@@ -0,0 +1,8 @@
package games.dmg.spigotquestboard;
import java.util.UUID;
@FunctionalInterface
interface BoardProximity {
boolean isWithin(UUID worldId, double x, double y, double z, double maximumDistance);
}
@@ -7,7 +7,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
final class BoardRegistry {
final class BoardRegistry implements BoardProximity {
private final BoardRepository repository;
private Map<BoardId, RegisteredBoard> boards;
@@ -46,6 +46,30 @@ final class BoardRegistry {
return boards.size();
}
@Override
public synchronized boolean isWithin(
java.util.UUID worldId,
double x,
double y,
double z,
double maximumDistance
) {
Objects.requireNonNull(worldId, "worldId");
if (maximumDistance < 0.0) {
return false;
}
double maximumDistanceSquared = maximumDistance * maximumDistance;
return boards.keySet().stream()
.filter(id -> id.worldId().equals(worldId))
.anyMatch(id -> {
double deltaX = x - id.x();
double deltaY = y - id.y();
double deltaZ = z - id.z();
return deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ
<= maximumDistanceSquared;
});
}
synchronized Map<BoardId, RegisteredBoard> registeredBoards() {
return Map.copyOf(boards);
}
@@ -1,6 +1,7 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.time.Clock;
import java.util.Objects;
import java.util.UUID;
import java.util.logging.Level;
@@ -15,11 +16,17 @@ final class BukkitIssuerNotifier implements IssuerNotifier, Listener {
private final QuestService quests;
private final Server server;
private final Logger logger;
private final Clock clock;
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.server = Objects.requireNonNull(server, "server");
this.logger = Objects.requireNonNull(logger, "logger");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
@@ -32,7 +39,20 @@ final class BukkitIssuerNotifier implements IssuerNotifier, Listener {
@EventHandler
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) {
@@ -6,11 +6,11 @@ import java.util.Locale;
import java.util.Optional;
import org.bukkit.Material;
final class BukkitBlockMaterialCatalog implements BlockMaterialCatalog {
final class BukkitItemMaterialCatalog implements ItemMaterialCatalog {
@Override
public Optional<String> normalizeBlock(String input) {
public Optional<String> normalizeItem(String 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.of(material.name());
@@ -20,7 +20,7 @@ final class BukkitBlockMaterialCatalog implements BlockMaterialCatalog {
public List<String> suggest(String prefix) {
String normalizedPrefix = prefix.toUpperCase(Locale.ROOT);
return Arrays.stream(Material.values())
.filter(material -> material.isBlock() && !material.isAir())
.filter(material -> material.isItem() && !material.isAir())
.map(Material::name)
.filter(name -> name.startsWith(normalizedPrefix))
.sorted()
@@ -2,10 +2,17 @@ package games.dmg.spigotquestboard;
import java.util.Base64;
import java.util.Objects;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
record EscrowItem(String material, int amount, String serializedItem, String customName) {
EscrowItem(String material, int amount, String serializedItem) {
this(material, amount, serializedItem, null);
}
record EscrowItem(String material, int amount, String serializedItem) {
EscrowItem {
Objects.requireNonNull(material, "material");
material = material.trim().toUpperCase(java.util.Locale.ROOT);
@@ -18,24 +25,43 @@ record EscrowItem(String material, int amount, String serializedItem) {
if (serializedItem != null && serializedItem.isBlank()) {
throw new IllegalArgumentException("Serialized item must not be blank");
}
customName = normalizeReadableName(customName);
}
static EscrowItem fromItemStack(ItemStack stack) {
Objects.requireNonNull(stack, "stack");
if (stack.getType().isAir() || stack.getAmount() <= 0) {
if (isAir(stack.getType()) || stack.getAmount() <= 0) {
throw new IllegalArgumentException("Reward stack must not be empty");
}
ItemStack snapshot = stack.clone();
return new EscrowItem(
snapshot.getType().name(), snapshot.getAmount(),
Base64.getEncoder().encodeToString(snapshot.serializeAsBytes())
Base64.getEncoder().encodeToString(snapshot.serializeAsBytes()),
readableCustomName(snapshot)
);
}
EscrowItem inferCustomName() {
if (customName != null || serializedItem == null) {
return this;
}
try {
String inferredName = readableCustomName(deserializeExactItem());
return inferredName == null
? this : new EscrowItem(material, amount, serializedItem, inferredName);
} catch (RuntimeException | LinkageError exception) {
return this;
}
}
ItemStack toItemStack() {
if (serializedItem == null) {
return new ItemStack(Objects.requireNonNull(Material.matchMaterial(material)), amount);
}
return deserializeExactItem();
}
private ItemStack deserializeExactItem() {
final ItemStack stack;
try {
stack = ItemStack.deserializeBytes(Base64.getDecoder().decode(serializedItem));
@@ -47,4 +73,34 @@ record EscrowItem(String material, int amount, String serializedItem) {
}
return stack;
}
private static boolean isAir(Material material) {
return material == Material.AIR || material == Material.CAVE_AIR
|| material == Material.VOID_AIR;
}
private static String readableCustomName(ItemStack stack) {
if (!stack.hasItemMeta()) {
return null;
}
ItemMeta metadata = stack.getItemMeta();
if (!metadata.hasCustomName()) {
return null;
}
Component name = metadata.customName();
if (name == null) {
return null;
}
return normalizeReadableName(
PlainTextComponentSerializer.plainText().serialize(name)
);
}
private static String normalizeReadableName(String name) {
if (name == null) {
return null;
}
String normalized = name.replaceAll("[\\p{Cntrl}\\s]+", " ").trim();
return normalized.isEmpty() ? null : normalized;
}
}
@@ -3,7 +3,7 @@ package games.dmg.spigotquestboard;
import java.util.List;
import java.util.Optional;
interface BlockMaterialCatalog {
Optional<String> normalizeBlock(String input);
interface ItemMaterialCatalog {
Optional<String> normalizeItem(String input);
List<String> suggest(String prefix);
}
@@ -113,7 +113,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
+ "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_material", "Requested item", "", 64),
new QuestBoardDialogSpec.Input("requested_quantity", "Quantity", "64", 10)
),
true
@@ -221,7 +221,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
}
ActionButton collect = callbackButton(
"Collect pending claims",
"Collect delivered blocks and returned rewards",
"Collect delivered items and returned rewards",
250,
respondingPlayer -> submitClaim(respondingPlayer)
);
@@ -247,7 +247,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
private ActionButton renderQuestAction(QuestBoardDialogSpec.Action action) {
return switch (action.route()) {
case COMPLETE -> callbackButton(
action.label(), "Deliver the requested blocks", 180,
action.label(), "Deliver the requested items", 180,
player -> submitCompletion(player, action.questId())
);
case CANCEL -> callbackButton(
@@ -328,7 +328,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
}
return claims.stream().map(claim -> {
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()
.map(item -> item.amount() + " " + item.material())
.reduce((left, right) -> left + ", " + right)
@@ -382,7 +382,7 @@ final class QuestBoardDialogUi implements QuestBoardUi {
player.sendMessage(exception.getMessage());
} catch (IOException exception) {
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) {
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
? "expired" : "cancelled";
@@ -14,6 +14,7 @@ import org.bukkit.entity.Player;
final class QuestCommand implements CommandExecutor, TabCompleter {
private static final List<String> QUANTITIES = List.of("1", "16", "32", "64");
private static final double COMMAND_CREATION_DISTANCE = 5.0;
private final QuestCreationGateway creator;
private final QuestBrowser browser;
private final QuestCompletionGateway completer;
@@ -21,6 +22,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
private final QuestClaimGateway claimant;
private final Clock clock;
private final PlayerCommandSettings playerCommands;
private final BoardProximity boardProximity;
QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this(creator, browser, null, null, null, clock);
@@ -53,7 +55,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestClaimGateway claimant,
Clock clock
) {
this(creator, browser, completer, canceller, claimant, clock, null);
this(creator, browser, completer, canceller, claimant, clock, null, null);
}
QuestCommand(
@@ -64,6 +66,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestClaimGateway claimant,
Clock clock,
PlayerCommandSettings playerCommands
) {
this(creator, browser, completer, canceller, claimant, clock, playerCommands, null);
}
QuestCommand(
QuestCreationGateway creator,
QuestBrowser browser,
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
QuestClaimGateway claimant,
Clock clock,
PlayerCommandSettings playerCommands,
BoardProximity boardProximity
) {
this.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser");
@@ -72,6 +87,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
this.claimant = claimant;
this.clock = Objects.requireNonNull(clock, "clock");
this.playerCommands = playerCommands;
this.boardProximity = boardProximity;
}
@Override
@@ -79,10 +95,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
CommandSender sender, Command command, String label, String[] arguments
) {
if (!playerCommandsEnabled()) {
sender.sendMessage(
"Player quest commands are disabled. Use a physical quest board instead."
);
return true;
if (isCreate(arguments) && sender instanceof Player player) {
if (!isNearBoard(player)) {
sender.sendMessage(
"Move closer to a quest board to create a quest by command."
);
return true;
}
} else {
sender.sendMessage(
"Player quest commands are disabled. Use a physical quest board instead."
);
return true;
}
}
if (arguments.length == 0
|| (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) {
@@ -123,7 +148,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(exception.getMessage());
} catch (IOException exception) {
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;
@@ -178,16 +203,27 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
public List<String> onTabComplete(
CommandSender sender, Command command, String alias, String[] arguments
) {
if (!playerCommandsEnabled() || !(sender instanceof Player)) {
if (!(sender instanceof Player player)) {
return List.of();
}
if (!playerCommandsEnabled()) {
if (!isNearBoard(player)) {
return List.of();
}
if (arguments.length == 1) {
return startsWith(List.of("create"), arguments[0]);
}
if (!isCreate(arguments)) {
return List.of();
}
}
if (arguments.length == 1) {
return startsWith(
List.of("create", "list", "complete", "cancel", "claim"), 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])) {
return startsWith(QUANTITIES, arguments[2]);
@@ -196,7 +232,6 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
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]
);
@@ -208,6 +243,21 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
return playerCommands == null || playerCommands.enabled();
}
private boolean isNearBoard(Player player) {
if (boardProximity == null) {
return false;
}
org.bukkit.Location location = player.getLocation();
return boardProximity.isWithin(
player.getWorld().getUID(), location.getX(), location.getY(), location.getZ(),
COMMAND_CREATION_DISTANCE
);
}
private static boolean isCreate(String[] arguments) {
return arguments.length > 0 && "create".equalsIgnoreCase(arguments[0]);
}
private static List<String> startsWith(List<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.stream()
@@ -217,7 +267,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
private static void usage(CommandSender sender) {
sender.sendMessage(
"Usage: /quests [list] | /quests create <block> <quantity> | "
"Usage: /quests [list] | /quests create <item> <quantity> | "
+ "/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.");
@@ -8,12 +8,12 @@ import org.bukkit.entity.Player;
final class QuestCreationController implements QuestCreationGateway {
private final QuestService quests;
private final BlockMaterialCatalog materials;
private final ItemMaterialCatalog materials;
private final HeldRewardInventory rewards;
QuestCreationController(
QuestService quests,
BlockMaterialCatalog materials,
ItemMaterialCatalog materials,
HeldRewardInventory rewards
) {
this.quests = Objects.requireNonNull(quests, "quests");
@@ -29,9 +29,9 @@ final class QuestCreationController implements QuestCreationGateway {
if (requestedAmount <= 0) {
throw new IllegalArgumentException("Requested quantity must be positive");
}
String material = materials.normalizeBlock(requestedMaterial)
String material = materials.normalizeItem(requestedMaterial)
.orElseThrow(() -> new IllegalArgumentException(
"Requested material must be a valid block"
"Requested material must be a valid item"
));
HeldRewardInventory.RemovedReward removed = rewards.remove(player);
try {
@@ -46,7 +46,7 @@ final class QuestCreationController implements QuestCreationGateway {
}
@Override
public List<String> suggestBlockMaterials(String prefix) {
public List<String> suggestItemMaterials(String prefix) {
return materials.suggest(prefix == null ? "" : prefix);
}
}
@@ -9,5 +9,5 @@ interface QuestCreationGateway {
Quest create(Player player, String requestedMaterial, int requestedAmount, Instant createdAt)
throws IOException;
List<String> suggestBlockMaterials(String prefix);
List<String> suggestItemMaterials(String prefix);
}
@@ -37,6 +37,9 @@ final class QuestListingFormatter {
}
private static String formatReward(EscrowItem item) {
if (item.customName() != null) {
return item.amount() + " × " + item.customName() + " (" + item.material() + ")";
}
return item.amount() + " × " + item.material()
+ (item.serializedItem() == null ? "" : " (with exact item data)");
}
@@ -33,7 +33,7 @@ final class QuestService implements QuestBrowser {
String material = Objects.requireNonNull(requestedMaterial, "requestedMaterial")
.trim().toUpperCase(Locale.ROOT);
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(
UUID.randomUUID(), issuerId, issuerName, material, requestedAmount,
@@ -90,7 +90,7 @@ final class QuestService implements QuestBrowser {
IssuerNotification notification = new IssuerNotification(
UUID.randomUUID(), active.id(), active.issuerId(),
"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
);
@@ -37,11 +37,11 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
}
QuestCreationGateway creator = new QuestCreationController(
quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory()
quests, new BukkitItemMaterialCatalog(), new BukkitHeldRewardInventory()
);
Clock clock = Clock.systemUTC();
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
quests, getServer(), getLogger()
quests, getServer(), getLogger(), clock
);
QuestCompletionGateway completer = new QuestCompletionController(
quests, new BukkitQuestCompletionInventory(), notifier
@@ -98,7 +98,8 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
return new CommandHandlers(
new QuestAdminCommand(boards, playerCommands),
new QuestCommand(
creator, browser, completer, canceller, claimant, clock, playerCommands
creator, browser, completer, canceller, claimant, clock, playerCommands,
boards
)
);
}
@@ -165,9 +165,14 @@ final class YamlQuestRepository implements QuestRepository {
if (data != null && !(data instanceof String)) {
throw new IllegalArgumentException("Invalid item-data");
}
Object customName = entry.get("custom-name");
if (customName != null && !(customName instanceof String)) {
throw new IllegalArgumentException("Invalid custom-name");
}
items.add(new EscrowItem(
requiredString(entry, "material"), requiredInteger(entry, "amount"), (String) data
));
requiredString(entry, "material"), requiredInteger(entry, "amount"),
(String) data, (String) customName
).inferCustomName());
}
return items;
}
@@ -181,6 +186,9 @@ final class YamlQuestRepository implements QuestRepository {
if (item.serializedItem() != null) {
serialized.put("item-data", item.serializedItem());
}
if (item.customName() != null) {
serialized.put("custom-name", item.customName());
}
serializedItems.add(serialized);
}
return serializedItems;
@@ -69,6 +69,24 @@ final class BoardRegistryTest {
assertEquals(0, repository.saveCount);
}
@Test
void proximityUsesSameWorldAndAcceptsExactFiveBlockBoundary() throws Exception {
RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD)));
BoardRegistry registry = new BoardRegistry(repository);
assertTrue(registry.isWithin(
BOARD.id().worldId(), BOARD.id().x() + 3.0, BOARD.id().y() + 4.0,
BOARD.id().z(), 5.0
));
assertFalse(registry.isWithin(
BOARD.id().worldId(), BOARD.id().x() + 3.01, BOARD.id().y() + 4.0,
BOARD.id().z(), 5.0
));
assertFalse(registry.isWithin(
UUID.randomUUID(), BOARD.id().x(), BOARD.id().y(), BOARD.id().z(), 5.0
));
}
@Test
void failedPersistenceDoesNotPublishBoard() throws Exception {
BoardRepository repository = new BoardRepository() {
@@ -4,15 +4,21 @@ 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.clearInvocations;
import static org.mockito.Mockito.never;
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;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test;
final class BukkitIssuerNotifierTest {
@@ -82,6 +88,90 @@ final class BukkitIssuerNotifierTest {
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 {
QuestService service = new QuestService(repository);
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));
}
}
@@ -0,0 +1,82 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Base64;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
final class EscrowItemTest {
@Test
void capturesStyledCustomNameAsSafePlainTextWithoutChangingSerializedBytes() {
byte[] exactBytes = new byte[] {1, 2, 3, 4};
ItemStack original = mock(ItemStack.class);
ItemStack snapshot = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
when(original.getType()).thenReturn(Material.PAPER);
when(original.getAmount()).thenReturn(2);
when(original.clone()).thenReturn(snapshot);
when(snapshot.getType()).thenReturn(Material.PAPER);
when(snapshot.getAmount()).thenReturn(2);
when(snapshot.serializeAsBytes()).thenReturn(exactBytes);
when(snapshot.hasItemMeta()).thenReturn(true);
when(snapshot.getItemMeta()).thenReturn(metadata);
when(metadata.hasCustomName()).thenReturn(true);
when(metadata.customName()).thenReturn(
Component.text(" Shopping", NamedTextColor.GOLD, TextDecoration.BOLD)
.append(Component.text("\nList ", NamedTextColor.BLUE))
);
EscrowItem escrow = EscrowItem.fromItemStack(original);
assertEquals("Shopping List", escrow.customName());
assertArrayEquals(exactBytes, Base64.getDecoder().decode(escrow.serializedItem()));
}
@Test
void ignoresVanillaDisplayNameWhenNoTrueCustomNameExists() {
ItemStack original = mock(ItemStack.class);
ItemStack snapshot = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
when(original.getType()).thenReturn(Material.DIAMOND);
when(original.getAmount()).thenReturn(3);
when(original.clone()).thenReturn(snapshot);
when(snapshot.getType()).thenReturn(Material.DIAMOND);
when(snapshot.getAmount()).thenReturn(3);
when(snapshot.serializeAsBytes()).thenReturn(new byte[] {9});
when(snapshot.hasItemMeta()).thenReturn(true);
when(snapshot.getItemMeta()).thenReturn(metadata);
when(metadata.hasCustomName()).thenReturn(false);
assertNull(EscrowItem.fromItemStack(original).customName());
}
@Test
void historicalInferenceDoesNotWeakenExactEnvelopeValidation() {
String data = Base64.getEncoder().encodeToString(new byte[] {7});
EscrowItem escrow = new EscrowItem("PAPER", 1, data);
ItemStack mismatched = mock(ItemStack.class);
when(mismatched.getType()).thenReturn(Material.BOOK);
when(mismatched.getAmount()).thenReturn(1);
try (MockedStatic<ItemStack> itemStacks = Mockito.mockStatic(ItemStack.class)) {
itemStacks.when(() -> ItemStack.deserializeBytes(Mockito.any(byte[].class)))
.thenReturn(mismatched);
assertEquals(escrow, escrow.inferCustomName());
assertThrows(IllegalStateException.class, escrow::toItemStack);
}
}
}
@@ -41,6 +41,10 @@ final class PhysicalBoardCreatorTest {
assertTrue(repository.state.boards().stream().allMatch(
board -> board.worldName().equals("survival")
));
assertTrue(PhysicalBoardPlan.create(ANCHOR, BoardFacing.NORTH)
.interactionLocations().stream().allMatch(location -> registry.isWithin(
location.worldId(), location.x(), location.y(), location.z(), 5.0
)));
}
@Test
@@ -11,6 +11,8 @@ import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
@@ -44,6 +46,54 @@ final class PluginCommandWiringTest {
assertTrue(worldLookups.get() > 0);
}
@Test
void playerCommandUsesBoardRegistryForNearbyCreation() throws Exception {
UUID worldId = UUID.randomUUID();
BoardId boardId = new BoardId(worldId, 10, 64, 20);
BoardRegistry boards = new BoardRegistry(new BoardRepository() {
@Override public BoardState load() {
return new BoardState(Set.of(new RegisteredBoard(boardId, "survival")));
}
@Override public void save(BoardState state) { }
});
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return false; }
@Override public void saveEnabled(boolean enabled) { }
}
);
QuestCreationGateway creator = mock(QuestCreationGateway.class);
Quest created = mock(Quest.class);
when(created.id()).thenReturn(UUID.randomUUID());
when(creator.create(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.any()
)).thenReturn(created);
SpigotQuestBoardPlugin.CommandHandlers handlers =
SpigotQuestBoardPlugin.commandHandlers(
boards, settings, creator, mock(QuestBrowser.class), null, null, null,
Clock.systemUTC()
);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
Player player = mock(Player.class);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 13, 68, 20));
assertTrue(handlers.quests().onCommand(
player, mock(Command.class), "quests", new String[] {"create", "stone", "1"}
));
verify(creator).create(
org.mockito.ArgumentMatchers.eq(player),
org.mockito.ArgumentMatchers.eq("stone"),
org.mockito.ArgumentMatchers.eq(1),
org.mockito.ArgumentMatchers.any()
);
}
@Test
void adminAndPlayerCommandsShareThePersistedSetting() throws Exception {
PlayerCommandSettings settings = new PlayerCommandSettings(
@@ -267,7 +267,7 @@ final class QuestBoardDialogUiTest {
}
@Test
void boardListingDistinguishesDeliveredBlocksFromReturnedRewards() {
void boardListingDistinguishesDeliveredItemsFromReturnedRewards() {
RecordingCreator creator = new RecordingCreator(false);
Player player = mock(Player.class);
UUID owner = UUID.randomUUID();
@@ -301,7 +301,7 @@ final class QuestBoardDialogUiTest {
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(
listing.contains("RETURNED REWARD (EXPIRATION) — 1 DIAMOND")
);
@@ -412,7 +412,7 @@ final class QuestBoardDialogUiTest {
}
@Override
public List<String> suggestBlockMaterials(String prefix) {
public List<String> suggestItemMaterials(String prefix) {
return List.of();
}
}
@@ -29,7 +29,7 @@ final class QuestClaimControllerTest {
assertEquals(new ClaimCollectionResult(3, 6), result);
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 expired quest"));
verify(player).sendMessage(contains("dropped at your feet, protected for you"));
@@ -14,15 +14,19 @@ import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class QuestCommandTest {
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
private static final UUID WORLD_ID =
UUID.fromString("00000000-0000-0000-0000-000000000099");
@Test
void disabledSettingGatesEveryPlayerCommandFormAndAutocomplete() throws Exception {
void disabledSettingGatesNonCreationCommandForms() throws Exception {
QuestCreationGateway creator = mock(QuestCreationGateway.class);
QuestBrowser browser = mock(QuestBrowser.class);
QuestCompletionGateway completer = mock(QuestCompletionGateway.class);
@@ -43,7 +47,6 @@ final class QuestCommandTest {
List<String[]> forms = List.of(
new String[] {},
new String[] {"list"},
new String[] {"create", "stone", "1"},
new String[] {"complete", "quest-id"},
new String[] {"cancel", "quest-id"},
new String[] {"claim"}
@@ -62,6 +65,97 @@ final class QuestCommandTest {
verifyNoInteractions(creator, browser, completer, canceller, claimant);
}
@Test
void disabledSettingAllowsNearbyCreationAndOnlyCreationAutocomplete() throws Exception {
RecordingCreator creator = new RecordingCreator();
PlayerCommandSettings settings = settings(false);
BoardProximity proximity = (worldId, x, y, z, maximumDistance) -> {
assertEquals(WORLD_ID, worldId);
assertEquals(4.0, x);
assertEquals(3.0, y);
assertEquals(0.0, z);
assertEquals(5.0, maximumDistance);
return true;
};
QuestCommand executor = new QuestCommand(
creator, now -> List.of(), null, null, null,
Clock.fixed(NOW, ZoneOffset.UTC), settings, proximity
);
Player player = playerAt(4.0, 3.0, 0.0);
Command command = mock(Command.class);
assertTrue(executor.onCommand(
player, command, "quests", new String[] {"create", "stone", "1"}
));
assertEquals(1, creator.calls);
assertEquals(List.of("create"), executor.onTabComplete(
player, command, "quests", new String[] {""}
));
assertTrue(executor.onTabComplete(
player, command, "quests", new String[] {"li"}
).isEmpty());
assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete(
player, command, "quests", new String[] {"create", "sto"}
));
assertEquals(List.of("1", "16", "32", "64"), executor.onTabComplete(
player, command, "quests", new String[] {"create", "stone", ""}
));
assertTrue(executor.onTabComplete(
player, command, "quests", new String[] {"complete", ""}
).isEmpty());
}
@Test
void disabledSettingRejectsAwayCreationBeforeCreatorOrEscrowGateway() throws Exception {
QuestCreationGateway creator = mock(QuestCreationGateway.class);
QuestCommand executor = new QuestCommand(
creator, mock(QuestBrowser.class), null, null, null,
Clock.fixed(NOW, ZoneOffset.UTC), settings(false),
(worldId, x, y, z, maximumDistance) -> false
);
Player player = playerAt(5.01, 0.0, 0.0);
Command command = mock(Command.class);
assertTrue(executor.onCommand(
player, command, "quests", new String[] {"create", "stone", "1"}
));
verify(player).sendMessage("Move closer to a quest board to create a quest by command.");
verifyNoInteractions(creator);
assertTrue(executor.onTabComplete(
player, command, "quests", new String[] {""}
).isEmpty());
assertTrue(executor.onTabComplete(
player, command, "quests", new String[] {"create", "sto"}
).isEmpty());
}
@Test
void enabledSettingPreservesAllCommandsWithoutConsultingProximity() throws Exception {
RecordingCreator creator = new RecordingCreator();
QuestBrowser browser = now -> List.of(creator.quest(1));
QuestCommand executor = new QuestCommand(
creator, browser, null, null, null, Clock.fixed(NOW, ZoneOffset.UTC),
settings(true),
(worldId, x, y, z, maximumDistance) -> {
throw new AssertionError("Enabled commands must bypass board proximity");
}
);
Player player = mock(Player.class);
Command command = mock(Command.class);
assertTrue(executor.onCommand(
player, command, "quests", new String[] {"create", "stone", "1"}
));
assertTrue(executor.onCommand(player, command, "quests", new String[] {"list"}));
assertEquals(List.of("create"), executor.onTabComplete(
player, command, "quests", new String[] {"cr"}
));
assertEquals(1, creator.calls);
}
@Test
void routesValidatedCreateArgumentsWithCurrentUtcTime() {
RecordingCreator creator = new RecordingCreator();
@@ -238,6 +332,23 @@ final class QuestCommandTest {
).isEmpty());
}
private static PlayerCommandSettings settings(boolean enabled) throws Exception {
return new PlayerCommandSettings(new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return enabled; }
@Override public void saveEnabled(boolean newValue) { }
});
}
private static Player playerAt(double x, double y, double z) {
World world = mock(World.class);
when(world.getUID()).thenReturn(WORLD_ID);
Location location = new Location(world, x, y, z);
Player player = mock(Player.class);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(location);
return player;
}
private static QuestCommand command(RecordingCreator creator) {
return command(creator, now -> List.of());
}
@@ -306,7 +417,7 @@ final class QuestCommandTest {
}
@Override
public List<String> suggestBlockMaterials(String prefix) {
public List<String> suggestItemMaterials(String prefix) {
suggestionPrefix = prefix;
return List.of("STONE", "STONE_BRICKS");
}
@@ -71,9 +71,9 @@ final class QuestCreationControllerTest {
private static QuestCreationController controller(
MemoryQuestRepository repository, HeldRewardInventory inventory
) throws IOException {
BlockMaterialCatalog catalog = new BlockMaterialCatalog() {
ItemMaterialCatalog catalog = new ItemMaterialCatalog() {
@Override
public Optional<String> normalizeBlock(String input) {
public Optional<String> normalizeItem(String input) {
return "stone".equalsIgnoreCase(input) ? Optional.of("STONE") : Optional.empty();
}
@@ -32,6 +32,19 @@ final class QuestListingFormatterTest {
assertTrue(listing.contains("Time remaining: 1d 1h 1m 1s"));
}
@Test
void showsCustomRewardNameTogetherWithMaterialType() {
Quest quest = quest(
NOW.plusSeconds(60),
List.of(new EscrowItem("PAPER", 1, "opaque-exact-data", "Shopping List"))
);
String listing = QuestListingFormatter.format(quest, NOW);
assertTrue(listing.contains("1 × Shopping List (PAPER)"));
assertFalse(listing.contains("PAPER (with exact item data)"));
}
@Test
void roundsAnActiveSubsecondBoundaryUpToOneSecond() {
Quest quest = quest(NOW.plusNanos(1), List.of(new EscrowItem("DIAMOND", 1, null)));
@@ -1,8 +1,11 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
@@ -12,8 +15,14 @@ import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
final class YamlQuestRepositoryTest {
@TempDir Path temporaryDirectory;
@@ -53,6 +62,90 @@ final class YamlQuestRepositoryTest {
assertTrue(yaml.contains(itemData));
}
@Test
void persistsCustomNamesForRewardAndClaimItems() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml");
YamlQuestRepository repository = new YamlQuestRepository(path);
UUID ownerId = UUID.randomUUID();
UUID questId = UUID.randomUUID();
Quest quest = new Quest(
questId, ownerId, "Issuer", "STONE", 1,
List.of(new EscrowItem("PAPER", 1, "reward-data", "Shopping List")),
Instant.parse("2026-09-05T03:00:00Z"),
Instant.parse("2026-09-12T03:00:00Z"), QuestStatus.COMPLETED
);
QuestClaim claim = new QuestClaim(
UUID.randomUUID(), questId, ownerId,
List.of(new EscrowItem("BOOK", 1, "claim-data", "Field Notes")),
Instant.parse("2026-09-05T03:01:00Z")
);
QuestState state = new QuestState(
Map.of(questId, quest), Map.of(ownerId, List.of(claim)), Map.of()
);
repository.save(state);
assertEquals(state, repository.load());
String yaml = Files.readString(path);
assertTrue(yaml.contains("custom-name: Shopping List"));
assertTrue(yaml.contains("custom-name: Field Notes"));
}
@Test
void infersHistoricalCustomNameFromExactDataAndPersistsItOnNextSave() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml");
String itemData = Base64.getEncoder().encodeToString(new byte[] {7});
writeHistoricalQuest(path, itemData);
ItemStack stack = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
when(stack.getType()).thenReturn(Material.PAPER);
when(stack.getAmount()).thenReturn(1);
when(stack.hasItemMeta()).thenReturn(true);
when(stack.getItemMeta()).thenReturn(metadata);
when(metadata.hasCustomName()).thenReturn(true);
when(metadata.customName()).thenReturn(Component.text("Archived List"));
YamlQuestRepository repository = new YamlQuestRepository(path);
QuestState loaded;
try (MockedStatic<ItemStack> itemStacks = Mockito.mockStatic(ItemStack.class)) {
itemStacks.when(() -> ItemStack.deserializeBytes(Mockito.any(byte[].class)))
.thenReturn(stack);
loaded = repository.load();
}
assertEquals("Archived List", loaded.quests().values().iterator().next()
.reward().getFirst().customName());
assertEquals("Archived List", loaded.claims().values().iterator().next()
.getFirst().items().getFirst().customName());
repository.save(loaded);
assertTrue(Files.readString(path).contains("custom-name: Archived List"));
}
@Test
void malformedOrUnavailableHistoricalItemDataFallsBackWithoutFailingLoad() throws Exception {
Path malformedPath = temporaryDirectory.resolve("malformed.yml");
writeHistoricalQuest(malformedPath, "not-base64%%% ");
Quest malformedQuest = new YamlQuestRepository(malformedPath).load()
.quests().values().iterator().next();
assertNull(malformedQuest.reward().getFirst().customName());
assertTrue(QuestListingFormatter.format(
malformedQuest, Instant.parse("2026-09-06T03:00:00Z")
).contains("1 × PAPER (with exact item data)"));
Path unavailablePath = temporaryDirectory.resolve("unavailable.yml");
writeHistoricalQuest(unavailablePath, Base64.getEncoder().encodeToString(new byte[] {8}));
Quest unavailableQuest;
try (MockedStatic<ItemStack> itemStacks = Mockito.mockStatic(ItemStack.class)) {
itemStacks.when(() -> ItemStack.deserializeBytes(Mockito.any(byte[].class)))
.thenThrow(new IllegalStateException("server item codec unavailable"));
unavailableQuest = new YamlQuestRepository(unavailablePath).load()
.quests().values().iterator().next();
}
assertNull(unavailableQuest.reward().getFirst().customName());
}
@Test
void missingLifecycleStatusDefaultsToActiveForExistingYaml() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml");
@@ -161,6 +254,32 @@ final class YamlQuestRepositoryTest {
assertEquals(QuestClaimType.RETURNED_REWARD, claim.type());
}
private static void writeHistoricalQuest(Path path, String itemData) throws IOException {
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: 1
created-at: '2026-09-05T03:00:00Z'
expires-at: '2026-09-12T03:00:00Z'
reward:
- material: PAPER
amount: 1
item-data: '%s'
claims:
- id: 00000000-0000-0000-0000-000000000020
quest-id: 00000000-0000-0000-0000-000000000010
owner-id: 00000000-0000-0000-0000-000000000001
created-at: '2026-09-05T04:00:00Z'
items:
- material: PAPER
amount: 1
item-data: '%s'
""".formatted(itemData, itemData));
}
@Test
void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml");