6 Commits
Author SHA1 Message Date
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
dmg 07061a3622 fix(board): make readable signs glow
Release / release (push) Successful in 2m26s
CI / build (push) Successful in 1m4s
2026-09-05 09:36:21 -04:00
dmg 72e58383a2 feat(board): add readable physical board signs
Release / release (push) Successful in 2m29s
CI / build (push) Successful in 1m9s
2026-09-05 09:26:45 -04:00
dmg bb537b0556 feat(board): generate physical oak quest boards
Release / release (push) Successful in 3m8s
CI / build (push) Successful in 1m17s
2026-09-05 09:00:18 -04:00
42 changed files with 2527 additions and 49 deletions
+3 -2
View File
@@ -6,7 +6,7 @@ The approved behavior is specified in the [OKF knowledge bundle](knowledge/index
## Status ## Status
Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. Right-clicking any registered board opens a 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 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.
## 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 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 <block> <quantity>` with material and quantity autocomplete; other player command forms remain disabled.
```text ```text
/quests /quests
@@ -40,6 +40,7 @@ The `spigotquestboard.admin` permission is granted to server operators by defaul
```text ```text
/questadmin createboard /questadmin createboard
/questadmin createboard physical
/questadmin commands <enable|disable> /questadmin commands <enable|disable>
``` ```
+2
View File
@@ -9,6 +9,8 @@ okf_version: "0.1"
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, block 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
- [User stories](user-stories/index.md) - [User stories](user-stories/index.md)
+43
View File
@@ -83,3 +83,46 @@ description: Chronological record of material decisions affecting Spigot Quest B
- Split browsing, creation, and claims into dedicated native dialog screens with Back navigation. - Split browsing, creation, and claims into dedicated native dialog screens with Back navigation.
- Presented active quests as individual detail dialogs while retaining completion and issuer cancellation actions. - Presented active quests as individual detail dialogs while retaining completion and issuer cancellation actions.
- Verified 88 tests, including a 250-quest navigation case, and the plugin JAR with `./gradlew clean check jar`. - Verified 88 tests, including a 250-quest navigation case, and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-05 — Generated physical quest boards
- Added `/questadmin createboard physical` to generate a five-wide, four-tall board above the targeted ground anchor facing the administrator.
- Used oak-log pillars, an oak-plank face, and three oak wall signs with obfuscated decorative text.
- Registered all visible planks and signs in one durable operation and added obstruction checks plus block rollback when construction or persistence fails.
- Preserved existing single-block board creation and persisted board compatibility.
- Verified 103 tests and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-05 — Readable physical-board signage
- Added centered `Quest Board` and right-click instruction signs on the two layers above each generated board's three decorative signs.
- Registered all five signs and the plank face as shared-board interaction locations.
- Added exact legacy-structure detection and failure-safe startup upgrades for previously generated three-sign boards without touching custom or altered structures.
- Verified 111 tests and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-05 — Glowing readable board signs
- Enabled glowing front-side text on generated title and instruction signs while retaining non-glowing decorative signs.
- 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`.
+5 -2
View File
@@ -9,10 +9,13 @@ 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 a block-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 blocks 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)
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)
@@ -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] 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 blocks 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)
@@ -24,6 +24,10 @@ 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 blocks and complete a quest](us-005-deliver-blocks-and-complete-a-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] Player `/quests` commands are disabled by default.
- [x] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete. - [x] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete.
- [x] The command setting persists across server restarts. - [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] 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] `/questadmin` remains available to authorized administrators regardless of the player-command setting.
- [x] Unauthorized users cannot change the setting. - [x] Unauthorized users cannot change the setting.
@@ -25,3 +25,4 @@ As an **administrator**, I want to enable or disable player quest commands so th
- [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 a block-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)
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-010: Generate a physical quest-board structure" title: "US-010: Generate a physical quest-board structure"
description: Let an administrator generate and register a decorative oak quest board at a targeted ground anchor. description: Let an administrator generate and register a decorative oak quest board at a targeted ground anchor.
status: backlog status: done
--- ---
# US-010: Generate a physical quest-board structure # US-010: Generate a physical quest-board structure
@@ -11,17 +11,17 @@ As an **administrator**, I want quest-board creation to optionally construct a r
## Acceptance criteria ## Acceptance criteria
- [ ] `/questadmin createboard physical` generates and registers a physical quest board while the existing `/questadmin createboard` behavior remains unchanged. - [x] `/questadmin createboard physical` generates and registers a physical quest board while the existing `/questadmin createboard` behavior remains unchanged.
- [ ] The targeted block is treated as the ground anchor and remains unchanged. - [x] The targeted block is treated as the ground anchor and remains unchanged.
- [ ] The generated board faces the administrator and is five blocks wide and four blocks tall. - [x] The generated board faces the administrator and is five blocks wide and four blocks tall.
- [ ] The outer columns use oak-log pillars and the center uses oak planks. - [x] The outer columns use oak-log pillars and the center uses oak planks.
- [ ] Oak wall signs on the front display decorative obfuscated or gibberish text. - [x] Oak wall signs on the front display decorative obfuscated or gibberish text.
- [ ] Clicking generated visible planks or signs opens the same globally shared quest-board interface. - [x] Clicking generated visible planks or signs opens the same globally shared quest-board interface.
- [ ] Generation requires empty space and refuses to overwrite an existing structure. - [x] Generation requires empty space and refuses to overwrite an existing structure.
- [ ] Structure generation and interaction-location registration form one failure-safe transaction; failure restores changed blocks and registers nothing. - [x] Structure generation and interaction-location registration form one failure-safe transaction; failure restores changed blocks and registers nothing.
- [ ] Generated interaction locations persist across restarts and remain compatible with existing registered boards. - [x] Generated interaction locations persist across restarts and remain compatible with existing registered boards.
- [ ] The `physical` argument is offered through permission-aware command autocomplete. - [x] The `physical` argument is offered through permission-aware command autocomplete.
- [ ] Automated tests verify geometry, facing direction, obstruction handling, rollback, registration, persistence, and autocomplete. - [x] Automated tests verify geometry, facing direction, obstruction handling, rollback, registration, persistence, and autocomplete.
## Related ## Related
@@ -0,0 +1,28 @@
---
type: User Story
title: "US-011: Add readable physical-board signage"
description: Give generated quest boards a readable title and browsing instruction above their decorative signs.
status: done
---
# US-011: Add readable physical-board signage
As a **player**, I want readable signs on a generated quest board so that I know what it is and how to open it.
## Acceptance criteria
- [x] A generated physical board has a centered top sign reading `Quest Board` across its sign lines.
- [x] A centered sign immediately below instructs the player to right-click a sign below to browse quests.
- [x] The existing lower row retains three decorative signs with obfuscated or gibberish text.
- [x] All five signs and the visible oak planks open the globally shared quest-board interface.
- [x] New physical boards reserve and validate space for all five signs without overwriting blocks.
- [x] Existing generated boards with the prior three-sign layout are detected from their exact registered oak structure and upgraded with the two centered readable signs.
- [x] Existing custom single-block boards and unrelated structures are not modified.
- [x] Existing-board upgrades never overwrite occupied sign locations and persist new interaction locations failure-safely.
- [x] Automated tests verify sign placement, readable text, decorative text, interaction registration, obstruction handling, and prior-layout migration.
- [x] The title and instruction signs use glowing front-side text for readability while decorative signs remain non-glowing.
- [x] New boards receive glowing readable signs, and exact existing generated five-sign boards are refreshed on startup without modifying custom signs.
## Related
- [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.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 <block> <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 a block-delivery quest](us-003-create-a-block-delivery-quest.md)
- [US-004: Browse available quests](us-004-browse-available-quests.md)
@@ -0,0 +1,54 @@
package games.dmg.spigotquestboard;
import org.bukkit.block.BlockFace;
/** The direction from the board toward its viewer. */
enum BoardFacing {
NORTH(0, -1),
EAST(1, 0),
SOUTH(0, 1),
WEST(-1, 0);
private final int x;
private final int z;
BoardFacing(int x, int z) {
this.x = x;
this.z = z;
}
int x() {
return x;
}
int z() {
return z;
}
int rightX() {
return -z;
}
int rightZ() {
return x;
}
BlockFace blockFace() {
return switch (this) {
case NORTH -> BlockFace.NORTH;
case EAST -> BlockFace.EAST;
case SOUTH -> BlockFace.SOUTH;
case WEST -> BlockFace.WEST;
};
}
static BoardFacing towardPlayer(BlockFace playerFacing) {
return switch (playerFacing) {
case NORTH -> SOUTH;
case EAST -> WEST;
case SOUTH -> NORTH;
case WEST -> EAST;
default -> throw new IllegalArgumentException("Player must face a cardinal direction");
};
}
}
@@ -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);
}
@@ -1,12 +1,13 @@
package games.dmg.spigotquestboard; package games.dmg.spigotquestboard;
import java.io.IOException; import java.io.IOException;
import java.util.Collection;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
final class BoardRegistry { final class BoardRegistry implements BoardProximity {
private final BoardRepository repository; private final BoardRepository repository;
private Map<BoardId, RegisteredBoard> boards; private Map<BoardId, RegisteredBoard> boards;
@@ -16,12 +17,22 @@ final class BoardRegistry {
} }
synchronized BoardRegistrationResult register(RegisteredBoard board) throws IOException { synchronized BoardRegistrationResult register(RegisteredBoard board) throws IOException {
Objects.requireNonNull(board, "board"); return registerAll(Set.of(Objects.requireNonNull(board, "board")));
if (boards.containsKey(board.id())) { }
return BoardRegistrationResult.ALREADY_REGISTERED;
} synchronized BoardRegistrationResult registerAll(Collection<RegisteredBoard> additions)
throws IOException {
Objects.requireNonNull(additions, "additions");
Map<BoardId, RegisteredBoard> candidate = new LinkedHashMap<>(boards); Map<BoardId, RegisteredBoard> candidate = new LinkedHashMap<>(boards);
candidate.put(board.id(), board); for (RegisteredBoard board : additions) {
Objects.requireNonNull(board, "board");
if (candidate.putIfAbsent(board.id(), board) != null) {
return BoardRegistrationResult.ALREADY_REGISTERED;
}
}
if (additions.isEmpty()) {
return BoardRegistrationResult.CREATED;
}
repository.save(new BoardState(Set.copyOf(candidate.values()))); repository.save(new BoardState(Set.copyOf(candidate.values())));
boards = Map.copyOf(candidate); boards = Map.copyOf(candidate);
return BoardRegistrationResult.CREATED; return BoardRegistrationResult.CREATED;
@@ -35,6 +46,34 @@ final class BoardRegistry {
return boards.size(); 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);
}
private static Map<BoardId, RegisteredBoard> index(BoardState state) throws IOException { private static Map<BoardId, RegisteredBoard> index(BoardState state) throws IOException {
Map<BoardId, RegisteredBoard> indexed = new LinkedHashMap<>(); Map<BoardId, RegisteredBoard> indexed = new LinkedHashMap<>();
for (RegisteredBoard board : state.boards()) { for (RegisteredBoard board : state.boards()) {
@@ -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) {
@@ -0,0 +1,137 @@
package games.dmg.spigotquestboard;
import java.util.List;
import java.util.Objects;
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.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.Sign;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.WallSign;
import org.bukkit.block.sign.Side;
import org.bukkit.block.sign.SignSide;
final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld {
private static final Component DECORATIVE_TEXT = Component.text("xxxxxxxx")
.color(NamedTextColor.DARK_GREEN)
.decorate(TextDecoration.OBFUSCATED);
private static final List<Component> TITLE_TEXT = List.of(
Component.text("Quest"), Component.text("Board"), Component.empty(), Component.empty()
);
private static final List<Component> INSTRUCTION_TEXT = List.of(
Component.text("Right-click"), Component.text("a sign below"),
Component.text("to browse"), Component.text("quests")
);
private final World world;
BukkitPhysicalBoardWorld(World world) {
this.world = Objects.requireNonNull(world, "world");
}
@Override
public boolean isEmpty(BoardId location) {
return block(location).getType().isAir();
}
@Override
public Object snapshot(BoardId location) {
return block(location).getState();
}
@Override
public void place(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
Block block = block(placement.location());
Material material = Objects.requireNonNull(
Material.matchMaterial(placement.material()),
"Unknown physical board material " + placement.material()
);
block.setType(material, false);
if (!placement.sign()) {
return;
}
BlockData data = block.getBlockData();
if (!(data instanceof WallSign wallSign)) {
throw new IllegalStateException("Oak wall sign did not create wall-sign data");
}
wallSign.setFacing(facing.blockFace());
block.setBlockData(wallSign, false);
BlockState state = block.getState();
if (!(state instanceof Sign sign)) {
throw new IllegalStateException("Oak wall sign did not create sign state");
}
List<Component> lines = signLines(placement.signKind());
SignSide front = sign.getSide(Side.FRONT);
for (int line = 0; line < lines.size(); line++) {
front.line(line, lines.get(line));
}
front.setGlowingText(placement.signKind() != PhysicalBoardSignKind.DECORATIVE);
if (!sign.update(true, false)) {
throw new IllegalStateException("Could not configure physical quest-board sign");
}
}
@Override
public boolean matches(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
return matches(placement, facing,
placement.signKind() != PhysicalBoardSignKind.DECORATIVE);
}
@Override
public boolean matches(
PhysicalBoardPlan.Placement placement, BoardFacing facing, boolean glowingText
) {
Block block = block(placement.location());
Material material = Material.matchMaterial(placement.material());
if (material == null || block.getType() != material) {
return false;
}
if (!placement.sign()) {
return true;
}
if (!(block.getBlockData() instanceof WallSign wallSign)
|| wallSign.getFacing() != facing.blockFace()
|| !(block.getState() instanceof Sign sign)) {
return false;
}
SignSide front = sign.getSide(Side.FRONT);
if (front.isGlowingText() != glowingText) {
return false;
}
List<Component> expected = signLines(placement.signKind());
for (int line = 0; line < expected.size(); line++) {
if (!expected.get(line).equals(front.line(line))) {
return false;
}
}
return true;
}
@Override
public void restore(BoardId location, Object snapshot) {
if (!(snapshot instanceof BlockState state) || !state.update(true, false)) {
throw new IllegalStateException("Could not restore block at " + location);
}
}
static List<Component> signLines(PhysicalBoardSignKind kind) {
return switch (Objects.requireNonNull(kind, "kind")) {
case TITLE -> TITLE_TEXT;
case INSTRUCTION -> INSTRUCTION_TEXT;
case DECORATIVE -> List.of(
DECORATIVE_TEXT, DECORATIVE_TEXT, DECORATIVE_TEXT, DECORATIVE_TEXT
);
};
}
private Block block(BoardId location) {
if (!world.getUID().equals(location.worldId())) {
throw new IllegalArgumentException("Physical board location belongs to another world");
}
return world.getBlockAt(location.x(), location.y(), location.z());
}
}
@@ -2,10 +2,17 @@ package games.dmg.spigotquestboard;
import java.util.Base64; import java.util.Base64;
import java.util.Objects; 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.Material;
import org.bukkit.inventory.ItemStack; 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 { EscrowItem {
Objects.requireNonNull(material, "material"); Objects.requireNonNull(material, "material");
material = material.trim().toUpperCase(java.util.Locale.ROOT); 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()) { if (serializedItem != null && serializedItem.isBlank()) {
throw new IllegalArgumentException("Serialized item must not be blank"); throw new IllegalArgumentException("Serialized item must not be blank");
} }
customName = normalizeReadableName(customName);
} }
static EscrowItem fromItemStack(ItemStack stack) { static EscrowItem fromItemStack(ItemStack stack) {
Objects.requireNonNull(stack, "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"); throw new IllegalArgumentException("Reward stack must not be empty");
} }
ItemStack snapshot = stack.clone(); ItemStack snapshot = stack.clone();
return new EscrowItem( return new EscrowItem(
snapshot.getType().name(), snapshot.getAmount(), 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() { ItemStack toItemStack() {
if (serializedItem == null) { if (serializedItem == null) {
return new ItemStack(Objects.requireNonNull(Material.matchMaterial(material)), amount); return new ItemStack(Objects.requireNonNull(Material.matchMaterial(material)), amount);
} }
return deserializeExactItem();
}
private ItemStack deserializeExactItem() {
final ItemStack stack; final ItemStack stack;
try { try {
stack = ItemStack.deserializeBytes(Base64.getDecoder().decode(serializedItem)); stack = ItemStack.deserializeBytes(Base64.getDecoder().decode(serializedItem));
@@ -47,4 +73,34 @@ record EscrowItem(String material, int amount, String serializedItem) {
} }
return stack; 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;
}
} }
@@ -0,0 +1,7 @@
package games.dmg.spigotquestboard;
enum PhysicalBoardCreationResult {
CREATED,
OBSTRUCTED,
ALREADY_REGISTERED
}
@@ -0,0 +1,93 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.bukkit.block.Block;
final class PhysicalBoardCreator {
interface WorldFactory {
PhysicalBoardWorld open(Block anchor);
}
private final BoardRegistry registry;
private final WorldFactory worlds;
PhysicalBoardCreator(BoardRegistry registry, WorldFactory worlds) {
this.registry = Objects.requireNonNull(registry, "registry");
this.worlds = Objects.requireNonNull(worlds, "worlds");
}
PhysicalBoardCreationResult create(Block anchorBlock, BoardFacing facing) throws IOException {
Objects.requireNonNull(anchorBlock, "anchorBlock");
BoardId anchor = BoardId.from(anchorBlock);
String worldName = anchorBlock.getWorld().getName();
PhysicalBoardPlan plan = PhysicalBoardPlan.create(anchor, facing);
PhysicalBoardWorld world = worlds.open(anchorBlock);
for (PhysicalBoardPlan.Placement placement : plan.placements()) {
if (!world.isEmpty(placement.location())) {
return PhysicalBoardCreationResult.OBSTRUCTED;
}
}
for (BoardId location : plan.interactionLocations()) {
if (registry.contains(location)) {
return PhysicalBoardCreationResult.ALREADY_REGISTERED;
}
}
Map<BoardId, Object> snapshots = new LinkedHashMap<>();
try {
for (PhysicalBoardPlan.Placement placement : plan.placements()) {
snapshots.put(placement.location(), world.snapshot(placement.location()));
}
for (PhysicalBoardPlan.Placement placement : plan.placements()) {
world.place(placement, facing);
}
List<RegisteredBoard> registrations = plan.interactionLocations().stream()
.map(location -> new RegisteredBoard(location, worldName))
.toList();
BoardRegistrationResult result = registry.registerAll(registrations);
if (result != BoardRegistrationResult.CREATED) {
rollback(world, snapshots, null);
return PhysicalBoardCreationResult.ALREADY_REGISTERED;
}
return PhysicalBoardCreationResult.CREATED;
} catch (RuntimeException | IOException exception) {
rollback(world, snapshots, exception);
if (exception instanceof IOException ioException) {
throw ioException;
}
throw new IOException("Could not generate physical quest board", exception);
}
}
private static void rollback(
PhysicalBoardWorld world, Map<BoardId, Object> snapshots, Throwable failure
) throws IOException {
RuntimeException rollbackFailure = null;
List<Map.Entry<BoardId, Object>> entries = new ArrayList<>(snapshots.entrySet());
for (int index = entries.size() - 1; index >= 0; index--) {
Map.Entry<BoardId, Object> entry = entries.get(index);
try {
world.restore(entry.getKey(), entry.getValue());
} catch (RuntimeException exception) {
if (rollbackFailure == null) {
rollbackFailure = exception;
} else {
rollbackFailure.addSuppressed(exception);
}
}
}
if (rollbackFailure != null) {
if (failure != null) {
failure.addSuppressed(rollbackFailure);
} else {
throw new IOException("Could not fully restore physical quest board", rollbackFailure);
}
}
}
}
@@ -0,0 +1,102 @@
package games.dmg.spigotquestboard;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
record PhysicalBoardPlan(
BoardFacing facing,
List<Placement> placements,
Set<BoardId> interactionLocations
) {
PhysicalBoardPlan {
Objects.requireNonNull(facing, "facing");
placements = List.copyOf(Objects.requireNonNull(placements, "placements"));
interactionLocations = Set.copyOf(Objects.requireNonNull(
interactionLocations, "interactionLocations"
));
}
static PhysicalBoardPlan create(BoardId anchor, BoardFacing facing) {
Objects.requireNonNull(anchor, "anchor");
Objects.requireNonNull(facing, "facing");
List<Placement> placements = new ArrayList<>(25);
Set<BoardId> interactions = new LinkedHashSet<>(17);
for (int height = 1; height <= 4; height++) {
for (int lateral = -2; lateral <= 2; lateral++) {
BoardId location = offset(anchor, facing, lateral, height, 0);
String material = Math.abs(lateral) == 2 ? "OAK_LOG" : "OAK_PLANKS";
placements.add(new Placement(location, material));
if ("OAK_PLANKS".equals(material)) {
interactions.add(location);
}
}
}
for (int lateral = -1; lateral <= 1; lateral++) {
BoardId location = offset(anchor, facing, lateral, 2, 1);
placements.add(new Placement(
location, "OAK_WALL_SIGN", PhysicalBoardSignKind.DECORATIVE
));
interactions.add(location);
}
addSign(
placements, interactions, offset(anchor, facing, 0, 3, 1),
PhysicalBoardSignKind.INSTRUCTION
);
addSign(
placements, interactions, offset(anchor, facing, 0, 4, 1),
PhysicalBoardSignKind.TITLE
);
return new PhysicalBoardPlan(facing, placements, interactions);
}
private static void addSign(
List<Placement> placements,
Set<BoardId> interactions,
BoardId location,
PhysicalBoardSignKind kind
) {
placements.add(new Placement(location, "OAK_WALL_SIGN", kind));
interactions.add(location);
}
private static BoardId offset(
BoardId anchor, BoardFacing facing, int lateral, int vertical, int forward
) {
return new BoardId(
anchor.worldId(),
anchor.x() + lateral * facing.rightX() + forward * facing.x(),
anchor.y() + vertical,
anchor.z() + lateral * facing.rightZ() + forward * facing.z()
);
}
record Placement(
BoardId location,
String material,
PhysicalBoardSignKind signKind
) {
Placement {
Objects.requireNonNull(location, "location");
Objects.requireNonNull(material, "material");
if ("OAK_WALL_SIGN".equals(material) != (signKind != null)) {
throw new IllegalArgumentException("Only wall signs require sign metadata");
}
}
Placement(BoardId location, String material) {
this(
location,
material,
"OAK_WALL_SIGN".equals(material) ? PhysicalBoardSignKind.DECORATIVE : null
);
}
boolean sign() {
return signKind != null;
}
}
}
@@ -0,0 +1,7 @@
package games.dmg.spigotquestboard;
enum PhysicalBoardSignKind {
TITLE,
INSTRUCTION,
DECORATIVE
}
@@ -0,0 +1,244 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
final class PhysicalBoardUpgrader {
interface WorldFactory {
PhysicalBoardWorld open(UUID worldId);
}
private record Candidate(BoardId anchor, BoardFacing facing, String worldName) { }
private record RelativeInteraction(int lateral, int height, int forward) { }
private static final List<RelativeInteraction> LEGACY_INTERACTIONS = legacyInteractions();
private final BoardRegistry registry;
private final WorldFactory worlds;
PhysicalBoardUpgrader(BoardRegistry registry, WorldFactory worlds) {
this.registry = Objects.requireNonNull(registry, "registry");
this.worlds = Objects.requireNonNull(worlds, "worlds");
}
int upgrade() throws IOException {
int upgraded = 0;
Map<BoardId, RegisteredBoard> registered = registry.registeredBoards();
for (Candidate candidate : candidates(registered)) {
boolean legacy = hasLegacyRegistrations(candidate, registered);
boolean full = hasFullRegistrations(candidate, registered);
if (!legacy && !full) {
continue;
}
PhysicalBoardWorld world = worlds.open(candidate.anchor().worldId());
if (world == null) {
continue;
}
if (legacy && isLegacyBoard(candidate, world) && upgradeLegacy(candidate, world)) {
upgraded++;
registered = registry.registeredBoards();
} else if (full && isNonGlowingFullBoard(candidate, world)) {
refreshReadableSigns(candidate, world);
upgraded++;
}
}
return upgraded;
}
private static boolean hasLegacyRegistrations(
Candidate candidate, Map<BoardId, RegisteredBoard> registered
) {
PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing());
for (BoardId location : legacyInteractionLocations(plan)) {
RegisteredBoard board = registered.get(location);
if (board == null || !candidate.worldName().equals(board.worldName())) {
return false;
}
}
return newSigns(plan).stream().noneMatch(sign ->
registered.containsKey(sign.location())
);
}
private static boolean hasFullRegistrations(
Candidate candidate, Map<BoardId, RegisteredBoard> registered
) {
PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing());
for (BoardId location : plan.interactionLocations()) {
RegisteredBoard board = registered.get(location);
if (board == null || !candidate.worldName().equals(board.worldName())) {
return false;
}
}
return true;
}
private boolean isLegacyBoard(Candidate candidate, PhysicalBoardWorld world) {
PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing());
for (PhysicalBoardPlan.Placement placement : legacyPlacements(plan)) {
if (!world.matches(placement, candidate.facing())) {
return false;
}
}
for (PhysicalBoardPlan.Placement placement : newSigns(plan)) {
if (!world.isEmpty(placement.location())) {
return false;
}
}
return true;
}
private boolean isNonGlowingFullBoard(Candidate candidate, PhysicalBoardWorld world) {
PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing());
for (PhysicalBoardPlan.Placement placement : plan.placements()) {
boolean matches = placement.sign()
? world.matches(placement, candidate.facing(), false)
: world.matches(placement, candidate.facing());
if (!matches) {
return false;
}
}
return true;
}
private boolean upgradeLegacy(Candidate candidate, PhysicalBoardWorld world) throws IOException {
List<PhysicalBoardPlan.Placement> signs = readableSigns(
PhysicalBoardPlan.create(candidate.anchor(), candidate.facing())
);
Map<BoardId, Object> snapshots = new LinkedHashMap<>();
try {
for (PhysicalBoardPlan.Placement sign : signs) {
snapshots.put(sign.location(), world.snapshot(sign.location()));
}
for (PhysicalBoardPlan.Placement sign : signs) {
world.place(sign, candidate.facing());
}
List<RegisteredBoard> additions = signs.stream()
.map(sign -> new RegisteredBoard(sign.location(), candidate.worldName()))
.toList();
if (registry.registerAll(additions) != BoardRegistrationResult.CREATED) {
rollback(world, snapshots, null);
return false;
}
return true;
} catch (RuntimeException | IOException exception) {
rollback(world, snapshots, exception);
if (exception instanceof IOException ioException) {
throw ioException;
}
throw new IOException("Could not upgrade physical quest board", exception);
}
}
private static Set<Candidate> candidates(Map<BoardId, RegisteredBoard> registered) {
Set<Candidate> candidates = new LinkedHashSet<>();
for (RegisteredBoard board : registered.values()) {
for (BoardFacing facing : BoardFacing.values()) {
for (RelativeInteraction relative : LEGACY_INTERACTIONS) {
BoardId location = board.id();
candidates.add(new Candidate(new BoardId(
location.worldId(),
location.x() - relative.lateral() * facing.rightX()
- relative.forward() * facing.x(),
location.y() - relative.height(),
location.z() - relative.lateral() * facing.rightZ()
- relative.forward() * facing.z()
), facing, board.worldName()));
}
}
}
return candidates;
}
private static List<RelativeInteraction> legacyInteractions() {
List<RelativeInteraction> interactions = new ArrayList<>(15);
for (int height = 1; height <= 4; height++) {
for (int lateral = -1; lateral <= 1; lateral++) {
interactions.add(new RelativeInteraction(lateral, height, 0));
}
}
for (int lateral = -1; lateral <= 1; lateral++) {
interactions.add(new RelativeInteraction(lateral, 2, 1));
}
return List.copyOf(interactions);
}
private static List<PhysicalBoardPlan.Placement> legacyPlacements(PhysicalBoardPlan plan) {
return plan.placements().stream().filter(placement ->
!placement.sign() || placement.signKind() == PhysicalBoardSignKind.DECORATIVE
).toList();
}
private static Set<BoardId> legacyInteractionLocations(PhysicalBoardPlan plan) {
Set<BoardId> locations = new LinkedHashSet<>();
for (PhysicalBoardPlan.Placement placement : legacyPlacements(plan)) {
if ("OAK_PLANKS".equals(placement.material()) || placement.sign()) {
locations.add(placement.location());
}
}
return Set.copyOf(locations);
}
private void refreshReadableSigns(Candidate candidate, PhysicalBoardWorld world)
throws IOException {
List<PhysicalBoardPlan.Placement> signs = readableSigns(
PhysicalBoardPlan.create(candidate.anchor(), candidate.facing())
);
Map<BoardId, Object> snapshots = new LinkedHashMap<>();
try {
for (PhysicalBoardPlan.Placement sign : signs) {
snapshots.put(sign.location(), world.snapshot(sign.location()));
}
for (PhysicalBoardPlan.Placement sign : signs) {
world.place(sign, candidate.facing());
}
} catch (RuntimeException exception) {
rollback(world, snapshots, exception);
throw new IOException("Could not refresh physical quest-board signs", exception);
}
}
private static List<PhysicalBoardPlan.Placement> newSigns(PhysicalBoardPlan plan) {
return readableSigns(plan);
}
private static List<PhysicalBoardPlan.Placement> readableSigns(PhysicalBoardPlan plan) {
return plan.placements().stream().filter(placement ->
placement.signKind() == PhysicalBoardSignKind.TITLE
|| placement.signKind() == PhysicalBoardSignKind.INSTRUCTION
).toList();
}
private static void rollback(
PhysicalBoardWorld world, Map<BoardId, Object> snapshots, Throwable failure
) throws IOException {
RuntimeException rollbackFailure = null;
List<Map.Entry<BoardId, Object>> entries = new ArrayList<>(snapshots.entrySet());
for (int index = entries.size() - 1; index >= 0; index--) {
Map.Entry<BoardId, Object> entry = entries.get(index);
try {
world.restore(entry.getKey(), entry.getValue());
} catch (RuntimeException exception) {
if (rollbackFailure == null) {
rollbackFailure = exception;
} else {
rollbackFailure.addSuppressed(exception);
}
}
}
if (rollbackFailure != null) {
if (failure != null) {
failure.addSuppressed(rollbackFailure);
} else {
throw new IOException("Could not restore physical board upgrade", rollbackFailure);
}
}
}
}
@@ -0,0 +1,21 @@
package games.dmg.spigotquestboard;
interface PhysicalBoardWorld {
boolean isEmpty(BoardId location);
Object snapshot(BoardId location);
void place(PhysicalBoardPlan.Placement placement, BoardFacing facing);
default boolean matches(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
return false;
}
default boolean matches(
PhysicalBoardPlan.Placement placement, BoardFacing facing, boolean glowingText
) {
return false;
}
void restore(BoardId location, Object snapshot);
}
@@ -15,10 +15,26 @@ final class QuestAdminCommand implements CommandExecutor, TabCompleter {
private static final String PERMISSION = "spigotquestboard.admin"; private static final String PERMISSION = "spigotquestboard.admin";
private final BoardRegistry registry; private final BoardRegistry registry;
private final PlayerCommandSettings playerCommands; private final PlayerCommandSettings playerCommands;
private final PhysicalBoardCreator physicalBoards;
QuestAdminCommand(BoardRegistry registry, PlayerCommandSettings playerCommands) { QuestAdminCommand(BoardRegistry registry, PlayerCommandSettings playerCommands) {
this(
registry,
playerCommands,
new PhysicalBoardCreator(
registry, anchor -> new BukkitPhysicalBoardWorld(anchor.getWorld())
)
);
}
QuestAdminCommand(
BoardRegistry registry,
PlayerCommandSettings playerCommands,
PhysicalBoardCreator physicalBoards
) {
this.registry = Objects.requireNonNull(registry, "registry"); this.registry = Objects.requireNonNull(registry, "registry");
this.playerCommands = Objects.requireNonNull(playerCommands, "playerCommands"); this.playerCommands = Objects.requireNonNull(playerCommands, "playerCommands");
this.physicalBoards = Objects.requireNonNull(physicalBoards, "physicalBoards");
} }
@Override @Override
@@ -32,13 +48,19 @@ final class QuestAdminCommand implements CommandExecutor, TabCompleter {
if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) { if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) {
return updatePlayerCommands(sender, arguments[1]); return updatePlayerCommands(sender, arguments[1]);
} }
if (arguments.length != 1 || !"createboard".equalsIgnoreCase(arguments[0])) { if (arguments.length == 1 && "createboard".equalsIgnoreCase(arguments[0])) {
sender.sendMessage( createBoard(sender);
"Usage: /questadmin createboard | /questadmin commands enable|disable"
);
return true; return true;
} }
createBoard(sender); if (arguments.length == 2
&& "createboard".equalsIgnoreCase(arguments[0])
&& "physical".equalsIgnoreCase(arguments[1])) {
createPhysicalBoard(sender);
return true;
}
sender.sendMessage(
"Usage: /questadmin createboard [physical] | /questadmin commands enable|disable"
);
return true; return true;
} }
@@ -84,6 +106,36 @@ final class QuestAdminCommand implements CommandExecutor, TabCompleter {
} }
} }
private void createPhysicalBoard(CommandSender sender) {
if (!(sender instanceof Player player)) {
sender.sendMessage("A player must target the ground anchor block.");
return;
}
Block target = player.getTargetBlockExact(5);
if (target == null) {
sender.sendMessage("Target a ground anchor within five blocks.");
return;
}
try {
PhysicalBoardCreationResult result = physicalBoards.create(
target, BoardFacing.towardPlayer(player.getFacing())
);
switch (result) {
case CREATED -> sender.sendMessage("Physical quest board created.");
case OBSTRUCTED -> sender.sendMessage(
"The physical quest board needs 23 empty placement blocks."
);
case ALREADY_REGISTERED -> sender.sendMessage(
"A physical quest-board interaction location is already registered."
);
}
} catch (IOException | IllegalArgumentException exception) {
sender.sendMessage(
"The physical quest board could not be created. All changed blocks were restored."
);
}
}
@Override @Override
public List<String> onTabComplete( public List<String> onTabComplete(
CommandSender sender, Command command, String alias, String[] arguments CommandSender sender, Command command, String alias, String[] arguments
@@ -97,6 +149,9 @@ final class QuestAdminCommand implements CommandExecutor, TabCompleter {
if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) { if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) {
return startsWith(List.of("enable", "disable"), arguments[1]); return startsWith(List.of("enable", "disable"), arguments[1]);
} }
if (arguments.length == 2 && "createboard".equalsIgnoreCase(arguments[0])) {
return startsWith(List.of("physical"), arguments[1]);
}
return List.of(); return List.of();
} }
@@ -14,6 +14,7 @@ import org.bukkit.entity.Player;
final class QuestCommand implements CommandExecutor, TabCompleter { final class QuestCommand implements CommandExecutor, TabCompleter {
private static final List<String> QUANTITIES = List.of("1", "16", "32", "64"); 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 QuestCreationGateway creator;
private final QuestBrowser browser; private final QuestBrowser browser;
private final QuestCompletionGateway completer; private final QuestCompletionGateway completer;
@@ -21,6 +22,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
private final QuestClaimGateway claimant; private final QuestClaimGateway claimant;
private final Clock clock; private final Clock clock;
private final PlayerCommandSettings playerCommands; private final PlayerCommandSettings playerCommands;
private final BoardProximity boardProximity;
QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this(creator, browser, null, null, null, clock); this(creator, browser, null, null, null, clock);
@@ -53,7 +55,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestClaimGateway claimant, QuestClaimGateway claimant,
Clock clock Clock clock
) { ) {
this(creator, browser, completer, canceller, claimant, clock, null); this(creator, browser, completer, canceller, claimant, clock, null, null);
} }
QuestCommand( QuestCommand(
@@ -64,6 +66,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestClaimGateway claimant, QuestClaimGateway claimant,
Clock clock, Clock clock,
PlayerCommandSettings playerCommands 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.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser"); this.browser = Objects.requireNonNull(browser, "browser");
@@ -72,6 +87,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
this.claimant = claimant; this.claimant = claimant;
this.clock = Objects.requireNonNull(clock, "clock"); this.clock = Objects.requireNonNull(clock, "clock");
this.playerCommands = playerCommands; this.playerCommands = playerCommands;
this.boardProximity = boardProximity;
} }
@Override @Override
@@ -79,10 +95,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
CommandSender sender, Command command, String label, String[] arguments CommandSender sender, Command command, String label, String[] arguments
) { ) {
if (!playerCommandsEnabled()) { if (!playerCommandsEnabled()) {
sender.sendMessage( if (isCreate(arguments) && sender instanceof Player player) {
"Player quest commands are disabled. Use a physical quest board instead." if (!isNearBoard(player)) {
); sender.sendMessage(
return true; "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 if (arguments.length == 0
|| (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) { || (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) {
@@ -178,9 +203,20 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
public List<String> onTabComplete( public List<String> onTabComplete(
CommandSender sender, Command command, String alias, String[] arguments CommandSender sender, Command command, String alias, String[] arguments
) { ) {
if (!playerCommandsEnabled() || !(sender instanceof Player)) { if (!(sender instanceof Player player)) {
return List.of(); 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) { if (arguments.length == 1) {
return startsWith( return startsWith(
List.of("create", "list", "complete", "cancel", "claim"), arguments[0] List.of("create", "list", "complete", "cancel", "claim"), arguments[0]
@@ -196,7 +232,6 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
return startsWith(browser.completableQuestIds(clock.instant()), arguments[1]); return startsWith(browser.completableQuestIds(clock.instant()), arguments[1]);
} }
if (arguments.length == 2 && "cancel".equalsIgnoreCase(arguments[0])) { if (arguments.length == 2 && "cancel".equalsIgnoreCase(arguments[0])) {
Player player = (Player) sender;
return startsWith( return startsWith(
browser.cancellableQuestIds(player.getUniqueId(), clock.instant()), arguments[1] browser.cancellableQuestIds(player.getUniqueId(), clock.instant()), arguments[1]
); );
@@ -208,6 +243,21 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
return playerCommands == null || playerCommands.enabled(); 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) { private static List<String> startsWith(List<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT); String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.stream() return candidates.stream()
@@ -37,6 +37,9 @@ final class QuestListingFormatter {
} }
private static String formatReward(EscrowItem item) { private static String formatReward(EscrowItem item) {
if (item.customName() != null) {
return item.amount() + " × " + item.customName() + " (" + item.material() + ")";
}
return item.amount() + " × " + item.material() return item.amount() + " × " + item.material()
+ (item.serializedItem() == null ? "" : " (with exact item data)"); + (item.serializedItem() == null ? "" : " (with exact item data)");
} }
@@ -13,10 +13,15 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
final BoardRegistry boards; final BoardRegistry boards;
final QuestService quests; final QuestService quests;
final PlayerCommandSettings playerCommands; final PlayerCommandSettings playerCommands;
final int upgradedBoards;
try { try {
boards = new BoardRegistry(new YamlBoardRepository( boards = new BoardRegistry(new YamlBoardRepository(
getDataFolder().toPath().resolve("boards.yml") getDataFolder().toPath().resolve("boards.yml")
)); ));
upgradedBoards = upgradePhysicalBoards(boards, worldId -> {
org.bukkit.World world = getServer().getWorld(worldId);
return world == null ? null : new BukkitPhysicalBoardWorld(world);
});
quests = new QuestService(new YamlQuestRepository( quests = new QuestService(new YamlQuestRepository(
getDataFolder().toPath().resolve("quests.yml") getDataFolder().toPath().resolve("quests.yml")
)); ));
@@ -36,7 +41,7 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
); );
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
@@ -69,10 +74,17 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
); );
getLogger().info( getLogger().info(
"Spigot Quest Board enabled with " + boards.size() + " boards and " "Spigot Quest Board enabled with " + boards.size() + " boards and "
+ quests.state().quests().size() + " quests." + quests.state().quests().size() + " quests; upgraded " + upgradedBoards
+ " physical boards."
); );
} }
static int upgradePhysicalBoards(
BoardRegistry boards, PhysicalBoardUpgrader.WorldFactory worlds
) throws IOException {
return new PhysicalBoardUpgrader(boards, worlds).upgrade();
}
static CommandHandlers commandHandlers( static CommandHandlers commandHandlers(
BoardRegistry boards, BoardRegistry boards,
PlayerCommandSettings playerCommands, PlayerCommandSettings playerCommands,
@@ -86,7 +98,8 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
return new CommandHandlers( return new CommandHandlers(
new QuestAdminCommand(boards, playerCommands), new QuestAdminCommand(boards, playerCommands),
new QuestCommand( 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)) { if (data != null && !(data instanceof String)) {
throw new IllegalArgumentException("Invalid item-data"); 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( 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; return items;
} }
@@ -181,6 +186,9 @@ final class YamlQuestRepository implements QuestRepository {
if (item.serializedItem() != null) { if (item.serializedItem() != null) {
serialized.put("item-data", item.serializedItem()); serialized.put("item-data", item.serializedItem());
} }
if (item.customName() != null) {
serialized.put("custom-name", item.customName());
}
serializedItems.add(serialized); serializedItems.add(serialized);
} }
return serializedItems; return serializedItems;
+1 -1
View File
@@ -10,7 +10,7 @@ commands:
usage: /quests [list|create|complete|cancel|claim] usage: /quests [list|create|complete|cancel|claim]
questadmin: questadmin:
description: Administer Spigot Quest Board. description: Administer Spigot Quest Board.
usage: /questadmin <createboard|commands> usage: /questadmin <createboard [physical]|commands>
permission: spigotquestboard.admin permission: spigotquestboard.admin
permissions: permissions:
spigotquestboard.admin: spigotquestboard.admin:
@@ -26,6 +26,40 @@ final class BoardRegistryTest {
assertEquals(Set.of(BOARD), repository.saved.boards()); assertEquals(Set.of(BOARD), repository.saved.boards());
} }
@Test
void registersManyLocationsInOneRepositorySave() throws IOException {
RegisteredBoard second = new RegisteredBoard(
new BoardId(BOARD.id().worldId(), 2, 64, 2), "world"
);
RecordingRepository repository = new RecordingRepository(BoardState.empty());
BoardRegistry registry = new BoardRegistry(repository);
assertEquals(
BoardRegistrationResult.CREATED,
registry.registerAll(Set.of(BOARD, second))
);
assertEquals(1, repository.saveCount);
assertEquals(Set.of(BOARD, second), repository.saved.boards());
assertTrue(registry.contains(BOARD.id()));
assertTrue(registry.contains(second.id()));
}
@Test
void duplicateInBatchPublishesAndPersistsNothing() throws IOException {
RegisteredBoard conflict = new RegisteredBoard(BOARD.id(), "renamed-world");
RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD)));
BoardRegistry registry = new BoardRegistry(repository);
assertEquals(
BoardRegistrationResult.ALREADY_REGISTERED,
registry.registerAll(Set.of(conflict))
);
assertEquals(0, repository.saveCount);
assertEquals(1, registry.size());
}
@Test @Test
void duplicateDoesNotWrite() throws IOException { void duplicateDoesNotWrite() throws IOException {
RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD))); RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD)));
@@ -35,6 +69,24 @@ final class BoardRegistryTest {
assertEquals(0, repository.saveCount); 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 @Test
void failedPersistenceDoesNotPublishBoard() throws Exception { void failedPersistenceDoesNotPublishBoard() throws Exception {
BoardRepository repository = new BoardRepository() { 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.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,172 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.Sign;
import org.bukkit.block.data.type.WallSign;
import org.bukkit.block.sign.Side;
import org.bukkit.block.sign.SignSide;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class BukkitPhysicalBoardWorldTest {
@Test
void configuresWallSignFacingAndDecorativeObfuscatedText() {
UUID worldId = UUID.randomUUID();
BoardId location = new BoardId(worldId, 1, 65, 2);
World world = mock(World.class);
Block block = mock(Block.class);
WallSign wallSign = mock(WallSign.class);
Sign sign = mock(Sign.class);
SignSide signSide = mock(SignSide.class);
when(world.getUID()).thenReturn(worldId);
when(world.getBlockAt(1, 65, 2)).thenReturn(block);
when(block.getBlockData()).thenReturn(wallSign);
when(block.getState()).thenReturn(sign);
when(sign.getSide(Side.FRONT)).thenReturn(signSide);
when(sign.update(true, false)).thenReturn(true);
BukkitPhysicalBoardWorld adapter = new BukkitPhysicalBoardWorld(world);
adapter.place(
new PhysicalBoardPlan.Placement(location, "OAK_WALL_SIGN"),
BoardFacing.NORTH
);
verify(block).setType(Material.OAK_WALL_SIGN, false);
verify(wallSign).setFacing(org.bukkit.block.BlockFace.NORTH);
verify(block).setBlockData(wallSign, false);
ArgumentCaptor<Component> text = ArgumentCaptor.forClass(Component.class);
verify(signSide, times(4)).line(any(Integer.class), text.capture());
assertEquals(4, text.getAllValues().size());
assertTrue(text.getAllValues().stream().allMatch(component ->
component.decoration(TextDecoration.OBFUSCATED) == TextDecoration.State.TRUE
));
}
@Test
void matchesOnlyAnExactDecorativeSignForLegacyIdentification() {
UUID worldId = UUID.randomUUID();
BoardId location = new BoardId(worldId, 1, 65, 2);
World world = mock(World.class);
Block block = mock(Block.class);
WallSign wallSign = mock(WallSign.class);
Sign sign = mock(Sign.class);
SignSide signSide = mock(SignSide.class);
when(world.getUID()).thenReturn(worldId);
when(world.getBlockAt(1, 65, 2)).thenReturn(block);
when(block.getType()).thenReturn(Material.OAK_WALL_SIGN);
when(block.getBlockData()).thenReturn(wallSign);
when(block.getState()).thenReturn(sign);
when(wallSign.getFacing()).thenReturn(org.bukkit.block.BlockFace.NORTH);
when(sign.getSide(Side.FRONT)).thenReturn(signSide);
List<Component> decorative = BukkitPhysicalBoardWorld.signLines(
PhysicalBoardSignKind.DECORATIVE
);
for (int line = 0; line < decorative.size(); line++) {
when(signSide.line(line)).thenReturn(decorative.get(line));
}
BukkitPhysicalBoardWorld adapter = new BukkitPhysicalBoardWorld(world);
PhysicalBoardPlan.Placement placement = new PhysicalBoardPlan.Placement(
location, "OAK_WALL_SIGN", PhysicalBoardSignKind.DECORATIVE
);
assertTrue(adapter.matches(placement, BoardFacing.NORTH));
assertFalse(adapter.matches(placement, BoardFacing.SOUTH));
}
@Test
void readableSignsGlowWhileDecorativeSignsDoNot() {
UUID worldId = UUID.randomUUID();
BoardId location = new BoardId(worldId, 1, 65, 2);
World world = mock(World.class);
Block block = mock(Block.class);
WallSign wallSign = mock(WallSign.class);
Sign sign = mock(Sign.class);
SignSide signSide = mock(SignSide.class);
when(world.getUID()).thenReturn(worldId);
when(world.getBlockAt(1, 65, 2)).thenReturn(block);
when(block.getBlockData()).thenReturn(wallSign);
when(block.getState()).thenReturn(sign);
when(sign.getSide(Side.FRONT)).thenReturn(signSide);
when(sign.update(true, false)).thenReturn(true);
BukkitPhysicalBoardWorld adapter = new BukkitPhysicalBoardWorld(world);
adapter.place(new PhysicalBoardPlan.Placement(
location, "OAK_WALL_SIGN", PhysicalBoardSignKind.TITLE
), BoardFacing.NORTH);
verify(signSide).setGlowingText(true);
adapter.place(new PhysicalBoardPlan.Placement(
location, "OAK_WALL_SIGN", PhysicalBoardSignKind.DECORATIVE
), BoardFacing.NORTH);
verify(signSide).setGlowingText(false);
}
@Test
void rendersReadableTitleAndInstructionText() {
assertEquals(
List.of(Component.text("Quest"), Component.text("Board"),
Component.empty(), Component.empty()),
placedLines(PhysicalBoardSignKind.TITLE)
);
assertEquals(
List.of(Component.text("Right-click"), Component.text("a sign below"),
Component.text("to browse"), Component.text("quests")),
placedLines(PhysicalBoardSignKind.INSTRUCTION)
);
}
private static List<Component> placedLines(PhysicalBoardSignKind kind) {
UUID worldId = UUID.randomUUID();
BoardId location = new BoardId(worldId, 1, 65, 2);
World world = mock(World.class);
Block block = mock(Block.class);
WallSign wallSign = mock(WallSign.class);
Sign sign = mock(Sign.class);
SignSide signSide = mock(SignSide.class);
when(world.getUID()).thenReturn(worldId);
when(world.getBlockAt(1, 65, 2)).thenReturn(block);
when(block.getBlockData()).thenReturn(wallSign);
when(block.getState()).thenReturn(sign);
when(sign.getSide(Side.FRONT)).thenReturn(signSide);
when(sign.update(true, false)).thenReturn(true);
new BukkitPhysicalBoardWorld(world).place(
new PhysicalBoardPlan.Placement(location, "OAK_WALL_SIGN", kind),
BoardFacing.NORTH
);
ArgumentCaptor<Component> text = ArgumentCaptor.forClass(Component.class);
verify(signSide, times(4)).line(any(Integer.class), text.capture());
return text.getAllValues();
}
@Test
void restoresCapturedBukkitBlockState() {
UUID worldId = UUID.randomUUID();
BoardId location = new BoardId(worldId, 1, 65, 2);
World world = mock(World.class);
BlockState snapshot = mock(BlockState.class);
when(world.getUID()).thenReturn(worldId);
when(snapshot.update(true, false)).thenReturn(true);
new BukkitPhysicalBoardWorld(world).restore(location, snapshot);
verify(snapshot).update(true, false);
}
}
@@ -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);
}
}
}
@@ -0,0 +1,167 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.junit.jupiter.api.Test;
final class PhysicalBoardCreatorTest {
private static final UUID WORLD_ID = UUID.fromString(
"00000000-0000-0000-0000-000000000010"
);
private static final BoardId ANCHOR = new BoardId(WORLD_ID, 10, 64, 20);
@Test
void buildsAndAtomicallyRegistersEveryVisiblePanelBlock() throws Exception {
RecordingRepository repository = new RecordingRepository(false);
BoardRegistry registry = new BoardRegistry(repository);
FakeWorld world = new FakeWorld();
PhysicalBoardCreator creator = creator(registry, world);
assertEquals(
PhysicalBoardCreationResult.CREATED,
creator.create(anchorBlock(), BoardFacing.NORTH)
);
assertEquals(25, world.placed.size());
assertEquals("AIR", world.materials.getOrDefault(ANCHOR, "AIR"));
assertEquals(17, registry.size());
assertEquals(17, repository.state.boards().size());
assertEquals(1, repository.saves);
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
void obstructionPreflightChangesAndRegistersNothing() throws Exception {
RecordingRepository repository = new RecordingRepository(false);
BoardRegistry registry = new BoardRegistry(repository);
FakeWorld world = new FakeWorld();
BoardId obstruction = PhysicalBoardPlan.create(ANCHOR, BoardFacing.EAST)
.placements().stream()
.filter(placement -> placement.signKind() == PhysicalBoardSignKind.TITLE)
.findFirst().orElseThrow().location();
world.materials.put(obstruction, "STONE");
assertEquals(
PhysicalBoardCreationResult.OBSTRUCTED,
creator(registry, world).create(anchorBlock(), BoardFacing.EAST)
);
assertTrue(world.placed.isEmpty());
assertEquals("STONE", world.materials.get(obstruction));
assertEquals(0, repository.saves);
assertEquals(0, registry.size());
}
@Test
void placementFailureRestoresEveryChangedBlockAndRegistersNothing() throws Exception {
RecordingRepository repository = new RecordingRepository(false);
BoardRegistry registry = new BoardRegistry(repository);
FakeWorld world = new FakeWorld();
world.failPlacement = 11;
assertThrows(IOException.class, () -> creator(registry, world).create(
anchorBlock(), BoardFacing.SOUTH
));
assertTrue(world.materials.values().stream().allMatch("AIR"::equals));
assertEquals(0, repository.saves);
assertEquals(0, registry.size());
}
@Test
void persistenceFailureRestoresWholeStructureAndPublishesNothing() throws Exception {
RecordingRepository repository = new RecordingRepository(true);
BoardRegistry registry = new BoardRegistry(repository);
FakeWorld world = new FakeWorld();
assertThrows(IOException.class, () -> creator(registry, world).create(
anchorBlock(), BoardFacing.WEST
));
assertTrue(world.materials.values().stream().allMatch("AIR"::equals));
assertEquals(1, repository.saves);
assertEquals(0, registry.size());
}
private static PhysicalBoardCreator creator(BoardRegistry registry, FakeWorld world) {
return new PhysicalBoardCreator(registry, ignored -> world);
}
private static Block anchorBlock() {
World world = mock(World.class);
when(world.getUID()).thenReturn(WORLD_ID);
when(world.getName()).thenReturn("survival");
Block block = mock(Block.class);
when(block.getWorld()).thenReturn(world);
when(block.getX()).thenReturn(ANCHOR.x());
when(block.getY()).thenReturn(ANCHOR.y());
when(block.getZ()).thenReturn(ANCHOR.z());
return block;
}
private static final class FakeWorld implements PhysicalBoardWorld {
private final Map<BoardId, String> materials = new HashMap<>();
private final Map<BoardId, String> placed = new HashMap<>();
private int placements;
private int failPlacement = -1;
@Override public boolean isEmpty(BoardId location) {
return "AIR".equals(materials.getOrDefault(location, "AIR"));
}
@Override public Object snapshot(BoardId location) {
return materials.getOrDefault(location, "AIR");
}
@Override public void place(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
if (placements++ == failPlacement) {
throw new IllegalStateException("simulated sign configuration failure");
}
materials.put(placement.location(), placement.material());
placed.put(placement.location(), placement.material());
}
@Override public void restore(BoardId location, Object snapshot) {
materials.put(location, (String) snapshot);
}
}
private static final class RecordingRepository implements BoardRepository {
private BoardState state = new BoardState(Set.of());
private final boolean failSave;
private int saves;
private RecordingRepository(boolean failSave) {
this.failSave = failSave;
}
@Override public BoardState load() {
return state;
}
@Override public void save(BoardState state) throws IOException {
saves++;
if (failSave) {
throw new IOException("disk full");
}
this.state = state;
}
}
}
@@ -0,0 +1,97 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.HashSet;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PhysicalBoardPlanTest {
@Test
void createsFiveWideFourTallOakBoardAboveGroundAnchor() {
BoardId anchor = new BoardId(UUID.randomUUID(), 0, 64, 0);
PhysicalBoardPlan plan = PhysicalBoardPlan.create(anchor, BoardFacing.NORTH);
assertEquals(8, plan.placements().stream()
.filter(block -> block.material().equals("OAK_LOG")).count());
assertEquals(12, plan.placements().stream()
.filter(block -> block.material().equals("OAK_PLANKS")).count());
assertEquals(5, plan.placements().stream()
.filter(block -> block.material().equals("OAK_WALL_SIGN")).count());
assertEquals(17, plan.interactionLocations().size());
assertFalse(plan.placements().stream().anyMatch(block -> block.location().equals(anchor)));
assertEquals(-2, plan.placements().stream().mapToInt(block -> block.location().x()).min().orElseThrow());
assertEquals(2, plan.placements().stream().mapToInt(block -> block.location().x()).max().orElseThrow());
assertEquals(65, plan.placements().stream().mapToInt(block -> block.location().y()).min().orElseThrow());
assertEquals(68, plan.placements().stream().mapToInt(block -> block.location().y()).max().orElseThrow());
}
@Test
void rotatesWidthAndFrontMountedSignsForEveryFacing() {
BoardId anchor = new BoardId(UUID.randomUUID(), 10, 64, 20);
for (BoardFacing facing : BoardFacing.values()) {
PhysicalBoardPlan plan = PhysicalBoardPlan.create(anchor, facing);
assertEquals(25, plan.placements().size());
assertEquals(25, new HashSet<>(plan.placements().stream()
.map(PhysicalBoardPlan.Placement::location).toList()).size());
var backing = plan.placements().stream().filter(
placement -> !placement.sign()
).toList();
assertEquals(20, backing.size());
assertTrue(backing.stream().allMatch(placement ->
forward(anchor, placement.location(), facing) == 0
&& Math.abs(lateral(anchor, placement.location(), facing)) <= 2
&& placement.location().y() >= anchor.y() + 1
&& placement.location().y() <= anchor.y() + 4
));
var signs = plan.placements().stream().filter(
PhysicalBoardPlan.Placement::sign
).toList();
assertEquals(5, signs.size());
assertTrue(signs.stream().allMatch(sign ->
forward(anchor, sign.location(), facing) == 1
&& Math.abs(lateral(anchor, sign.location(), facing)) <= 1
&& sign.location().y() >= anchor.y() + 2
&& sign.location().y() <= anchor.y() + 4
));
assertEquals(1, signs.stream()
.filter(sign -> sign.signKind() == PhysicalBoardSignKind.TITLE).count());
assertEquals(1, signs.stream()
.filter(sign -> sign.signKind() == PhysicalBoardSignKind.INSTRUCTION).count());
assertEquals(3, signs.stream()
.filter(sign -> sign.signKind() == PhysicalBoardSignKind.DECORATIVE).count());
assertEquals(17, plan.interactionLocations().size());
assertTrue(plan.interactionLocations().containsAll(signs.stream()
.map(PhysicalBoardPlan.Placement::location).toList()));
assertTrue(backing.stream()
.filter(placement -> placement.material().equals("OAK_PLANKS"))
.allMatch(placement -> plan.interactionLocations().contains(
placement.location()
)));
}
}
private static int forward(BoardId anchor, BoardId location, BoardFacing facing) {
return (location.x() - anchor.x()) * facing.x()
+ (location.z() - anchor.z()) * facing.z();
}
private static int lateral(BoardId anchor, BoardId location, BoardFacing facing) {
return (location.x() - anchor.x()) * facing.rightX()
+ (location.z() - anchor.z()) * facing.rightZ();
}
@Test
void boardFrontFacesOppositeTheAdministratorsLookDirection() {
assertEquals(BoardFacing.SOUTH, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.NORTH));
assertEquals(BoardFacing.WEST, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.EAST));
assertEquals(BoardFacing.NORTH, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.SOUTH));
assertEquals(BoardFacing.EAST, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.WEST));
}
}
@@ -0,0 +1,303 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
final class PhysicalBoardUpgraderTest {
private static final UUID WORLD_ID = UUID.fromString(
"00000000-0000-0000-0000-000000000011"
);
private static final BoardId ANCHOR = new BoardId(WORLD_ID, 10, 64, 20);
private static final BoardFacing FACING = BoardFacing.NORTH;
@Test
void upgradesOnlyAnExactRegisteredLegacyStructure() throws Exception {
RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false);
BoardRegistry registry = new BoardRegistry(repository);
FakeWorld world = legacyWorld();
assertEquals(1, upgrader(registry, world).upgrade());
assertEquals(17, registry.size());
assertEquals(1, repository.saves);
assertEquals(Set.of(PhysicalBoardSignKind.TITLE, PhysicalBoardSignKind.INSTRUCTION),
world.placed.values().stream().map(PhysicalBoardPlan.Placement::signKind)
.collect(Collectors.toSet()));
assertEquals(2, world.placed.size());
}
@Test
void refreshesBothReadableSignsOnAnExactRegisteredFullBoard() throws Exception {
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
BoardRegistry registry = new BoardRegistry(repository);
FakeWorld world = fullWorld(false);
assertEquals(1, upgrader(registry, world).upgrade());
assertEquals(17, registry.size());
assertEquals(0, repository.saves);
assertEquals(Set.of(PhysicalBoardSignKind.TITLE, PhysicalBoardSignKind.INSTRUCTION),
world.placed.values().stream().map(PhysicalBoardPlan.Placement::signKind)
.collect(Collectors.toSet()));
assertTrue(newSigns().stream().allMatch(sign -> world.glowing.get(sign.location())));
}
@Test
void doesNotReportOrRefreshAnAlreadyGlowingFullBoard() throws Exception {
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
FakeWorld world = fullWorld(true);
assertEquals(0, upgrader(new BoardRegistry(repository), world).upgrade());
assertTrue(world.placed.isEmpty());
assertEquals(0, repository.saves);
}
@Test
void doesNotAlterCustomBoardsOrStructuresThatDoNotExactlyMatch() throws Exception {
RegisteredBoard custom = new RegisteredBoard(
new BoardId(WORLD_ID, 100, 70, 100), "survival"
);
RecordingRepository customRepository = new RecordingRepository(Set.of(custom), false);
FakeWorld customWorld = new FakeWorld();
assertEquals(0, upgrader(new BoardRegistry(customRepository), customWorld).upgrade());
assertTrue(customWorld.placed.isEmpty());
RecordingRepository changedRepository = new RecordingRepository(
legacyRegistrations(), false
);
FakeWorld changedWorld = legacyWorld();
PhysicalBoardPlan.Placement log = plan().placements().stream()
.filter(placement -> "OAK_LOG".equals(placement.material())).findFirst().orElseThrow();
changedWorld.existing.put(log.location(), new PhysicalBoardPlan.Placement(
log.location(), "STONE"
));
assertEquals(0, upgrader(new BoardRegistry(changedRepository), changedWorld).upgrade());
assertTrue(changedWorld.placed.isEmpty());
}
@Test
void doesNotRefreshAFullBoardWithCustomSignText() throws Exception {
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
FakeWorld world = fullWorld(false);
PhysicalBoardPlan.Placement title = newSigns().stream()
.filter(sign -> sign.signKind() == PhysicalBoardSignKind.TITLE)
.findFirst().orElseThrow();
world.existing.put(title.location(), new PhysicalBoardPlan.Placement(
title.location(), title.material(), PhysicalBoardSignKind.INSTRUCTION
));
assertEquals(0, upgrader(new BoardRegistry(repository), world).upgrade());
assertTrue(world.placed.isEmpty());
assertTrue(newSigns().stream().noneMatch(sign -> world.glowing.get(sign.location())));
}
@Test
void doesNotOverwriteAnOccupiedNewSignCell() throws Exception {
RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false);
BoardRegistry registry = new BoardRegistry(repository);
FakeWorld world = legacyWorld();
BoardId title = newSigns().stream()
.filter(sign -> sign.signKind() == PhysicalBoardSignKind.TITLE)
.findFirst().orElseThrow().location();
world.existing.put(title, new PhysicalBoardPlan.Placement(title, "STONE"));
assertEquals(0, upgrader(registry, world).upgrade());
assertTrue(world.placed.isEmpty());
assertEquals(15, registry.size());
assertEquals(0, repository.saves);
}
@Test
void rollsBackBothCellsWhenSignConstructionFails() throws Exception {
RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false);
BoardRegistry registry = new BoardRegistry(repository);
FakeWorld world = legacyWorld();
world.failPlacement = 1;
assertThrows(IOException.class, () -> upgrader(registry, world).upgrade());
assertTrue(newSigns().stream().allMatch(sign -> !world.existing.containsKey(sign.location())));
assertEquals(15, registry.size());
assertEquals(0, repository.saves);
}
@Test
void rollsBackBothReadableSignsWhenFullBoardRefreshFails() throws Exception {
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
FakeWorld world = fullWorld(false);
world.failPlacement = 1;
assertThrows(IOException.class,
() -> upgrader(new BoardRegistry(repository), world).upgrade());
assertTrue(newSigns().stream().noneMatch(sign -> world.glowing.get(sign.location())));
assertEquals(0, repository.saves);
}
@Test
void rollsBackBothCellsWhenAtomicPersistenceFails() throws Exception {
RecordingRepository repository = new RecordingRepository(legacyRegistrations(), true);
BoardRegistry registry = new BoardRegistry(repository);
FakeWorld world = legacyWorld();
assertThrows(IOException.class, () -> upgrader(registry, world).upgrade());
assertTrue(newSigns().stream().allMatch(sign -> !world.existing.containsKey(sign.location())));
assertEquals(15, registry.size());
assertEquals(1, repository.saves);
}
private static PhysicalBoardUpgrader upgrader(BoardRegistry registry, FakeWorld world) {
return new PhysicalBoardUpgrader(
registry, worldId -> WORLD_ID.equals(worldId) ? world : null
);
}
private static PhysicalBoardPlan plan() {
return PhysicalBoardPlan.create(ANCHOR, FACING);
}
private static Set<RegisteredBoard> legacyRegistrations() {
return plan().placements().stream().filter(placement ->
"OAK_PLANKS".equals(placement.material())
|| placement.signKind() == PhysicalBoardSignKind.DECORATIVE
).map(placement -> new RegisteredBoard(placement.location(), "survival"))
.collect(Collectors.toSet());
}
private static Set<RegisteredBoard> fullRegistrations() {
return plan().interactionLocations().stream()
.map(location -> new RegisteredBoard(location, "survival"))
.collect(Collectors.toSet());
}
private static FakeWorld legacyWorld() {
FakeWorld world = new FakeWorld();
plan().placements().stream().filter(placement ->
placement.signKind() != PhysicalBoardSignKind.TITLE
&& placement.signKind() != PhysicalBoardSignKind.INSTRUCTION
).forEach(placement -> {
world.existing.put(placement.location(), placement);
world.facings.put(placement.location(), FACING);
});
return world;
}
private static FakeWorld fullWorld(boolean readableGlowing) {
FakeWorld world = new FakeWorld();
plan().placements().forEach(placement -> {
world.existing.put(placement.location(), placement);
if (placement.sign()) {
world.facings.put(placement.location(), FACING);
world.glowing.put(placement.location(),
placement.signKind() != PhysicalBoardSignKind.DECORATIVE && readableGlowing);
}
});
return world;
}
private static java.util.List<PhysicalBoardPlan.Placement> newSigns() {
return plan().placements().stream().filter(placement ->
placement.signKind() == PhysicalBoardSignKind.TITLE
|| placement.signKind() == PhysicalBoardSignKind.INSTRUCTION
).toList();
}
private static final class FakeWorld implements PhysicalBoardWorld {
private record Snapshot(
PhysicalBoardPlan.Placement placement, BoardFacing facing, Boolean glowing
) { }
private final Map<BoardId, PhysicalBoardPlan.Placement> existing = new HashMap<>();
private final Map<BoardId, BoardFacing> facings = new HashMap<>();
private final Map<BoardId, Boolean> glowing = new HashMap<>();
private final Map<BoardId, PhysicalBoardPlan.Placement> placed = new HashMap<>();
private int placements;
private int failPlacement = -1;
@Override public boolean isEmpty(BoardId location) {
return !existing.containsKey(location);
}
@Override public Object snapshot(BoardId location) {
return new Snapshot(
existing.get(location), facings.get(location), glowing.get(location)
);
}
@Override public void place(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
if (placements++ == failPlacement) {
throw new IllegalStateException("simulated sign build failure");
}
existing.put(placement.location(), placement);
facings.put(placement.location(), facing);
glowing.put(placement.location(),
placement.signKind() != PhysicalBoardSignKind.DECORATIVE);
placed.put(placement.location(), placement);
}
@Override public boolean matches(
PhysicalBoardPlan.Placement placement, BoardFacing facing
) {
return placement.equals(existing.get(placement.location()))
&& (!placement.sign() || facing == facings.get(placement.location()));
}
@Override public boolean matches(
PhysicalBoardPlan.Placement placement, BoardFacing facing, boolean glowingText
) {
return matches(placement, facing)
&& glowingText == glowing.getOrDefault(placement.location(), false);
}
@Override public void restore(BoardId location, Object snapshot) {
Snapshot captured = (Snapshot) snapshot;
restore(existing, location, captured.placement());
restore(facings, location, captured.facing());
restore(glowing, location, captured.glowing());
}
private static <T> void restore(Map<BoardId, T> values, BoardId location, T value) {
if (value == null) {
values.remove(location);
} else {
values.put(location, value);
}
}
}
private static final class RecordingRepository implements BoardRepository {
private BoardState state;
private final boolean failSave;
private int saves;
private RecordingRepository(Set<RegisteredBoard> boards, boolean failSave) {
state = new BoardState(boards);
this.failSave = failSave;
}
@Override public BoardState load() {
return state;
}
@Override public void save(BoardState state) throws IOException {
saves++;
if (failSave) {
throw new IOException("disk full");
}
this.state = state;
}
}
}
@@ -1,5 +1,6 @@
package games.dmg.spigotquestboard; package games.dmg.spigotquestboard;
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.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -8,12 +9,91 @@ import static org.mockito.Mockito.when;
import java.time.Clock; import java.time.Clock;
import java.util.List; import java.util.List;
import java.util.Set; 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.Command;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class PluginCommandWiringTest { final class PluginCommandWiringTest {
@Test
void startupPhysicalBoardUpgradeDelegatesToLoadedWorlds() throws Exception {
UUID worldId = UUID.randomUUID();
PhysicalBoardPlan plan = PhysicalBoardPlan.create(
new BoardId(worldId, 1, 64, 1), BoardFacing.NORTH
);
Set<RegisteredBoard> legacy = plan.placements().stream()
.filter(placement -> "OAK_PLANKS".equals(placement.material())
|| placement.signKind() == PhysicalBoardSignKind.DECORATIVE)
.map(placement -> new RegisteredBoard(placement.location(), "survival"))
.collect(java.util.stream.Collectors.toSet());
BoardRegistry registry = new BoardRegistry(new BoardRepository() {
@Override public BoardState load() { return new BoardState(legacy); }
@Override public void save(BoardState state) { }
});
AtomicInteger worldLookups = new AtomicInteger();
int upgraded = SpigotQuestBoardPlugin.upgradePhysicalBoards(registry, id -> {
assertEquals(worldId, id);
worldLookups.incrementAndGet();
return null;
});
assertEquals(0, upgraded);
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 @Test
void adminAndPlayerCommandsShareThePersistedSetting() throws Exception { void adminAndPlayerCommandsShareThePersistedSetting() throws Exception {
PlayerCommandSettings settings = new PlayerCommandSettings( PlayerCommandSettings settings = new PlayerCommandSettings(
@@ -12,7 +12,9 @@ import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.BlockFace;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
@@ -126,9 +128,12 @@ final class QuestAdminCommandTest {
assertEquals(List.of("disable"), executor.onTabComplete( assertEquals(List.of("disable"), executor.onTabComplete(
authorized, command, "questadmin", new String[] {"commands", "di"} authorized, command, "questadmin", new String[] {"commands", "di"}
)); ));
assertTrue(executor.onTabComplete( assertEquals(List.of("physical"), executor.onTabComplete(
authorized, command, "questadmin", new String[] {"createboard", ""} authorized, command, "questadmin", new String[] {"createboard", ""}
).isEmpty()); ));
assertEquals(List.of("physical"), executor.onTabComplete(
authorized, command, "questadmin", new String[] {"createboard", "phy"}
));
assertTrue(executor.onTabComplete( assertTrue(executor.onTabComplete(
unauthorized, command, "questadmin", new String[] {""} unauthorized, command, "questadmin", new String[] {""}
).isEmpty()); ).isEmpty());
@@ -161,6 +166,95 @@ final class QuestAdminCommandTest {
))); )));
} }
@Test
void physicalArgumentBuildsAndRegistersAllInteractionLocations() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository();
BoardRegistry registry = new BoardRegistry(repository);
AtomicInteger placements = new AtomicInteger();
PhysicalBoardWorld worldAdapter = new PhysicalBoardWorld() {
@Override public boolean isEmpty(BoardId location) { return true; }
@Override public Object snapshot(BoardId location) { return "AIR"; }
@Override public void place(
PhysicalBoardPlan.Placement placement, BoardFacing facing
) {
assertEquals(BoardFacing.SOUTH, facing);
placements.incrementAndGet();
}
@Override public void restore(BoardId location, Object snapshot) { }
};
QuestAdminCommand executor = new QuestAdminCommand(
registry,
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository()),
new PhysicalBoardCreator(registry, ignored -> worldAdapter)
);
Player player = mock(Player.class);
Block anchor = mock(Block.class);
World world = mock(World.class);
UUID worldId = UUID.randomUUID();
when(player.hasPermission("spigotquestboard.admin")).thenReturn(true);
when(player.getTargetBlockExact(5)).thenReturn(anchor);
when(player.getFacing()).thenReturn(BlockFace.NORTH);
when(anchor.getWorld()).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("world");
when(anchor.getX()).thenReturn(3);
when(anchor.getY()).thenReturn(70);
when(anchor.getZ()).thenReturn(4);
assertTrue(executor.onCommand(
player, mock(Command.class), "questadmin",
new String[] {"createboard", "physical"}
));
assertEquals(25, placements.get());
assertEquals(17, repository.state.boards().size());
verify(player).sendMessage("Physical quest board created.");
}
@Test
void physicalBoardObstructionIsReportedWithoutRegistration() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository();
BoardRegistry registry = new BoardRegistry(repository);
PhysicalBoardWorld obstructedWorld = new PhysicalBoardWorld() {
@Override public boolean isEmpty(BoardId location) { return false; }
@Override public Object snapshot(BoardId location) {
throw new AssertionError("obstructed generation must not snapshot");
}
@Override public void place(
PhysicalBoardPlan.Placement placement, BoardFacing facing
) {
throw new AssertionError("obstructed generation must not place");
}
@Override public void restore(BoardId location, Object snapshot) {
throw new AssertionError("obstructed generation must not restore");
}
};
QuestAdminCommand executor = new QuestAdminCommand(
registry,
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository()),
new PhysicalBoardCreator(registry, ignored -> obstructedWorld)
);
Player player = mock(Player.class);
Block anchor = mock(Block.class);
World world = mock(World.class);
when(player.hasPermission("spigotquestboard.admin")).thenReturn(true);
when(player.getTargetBlockExact(5)).thenReturn(anchor);
when(player.getFacing()).thenReturn(BlockFace.SOUTH);
when(anchor.getWorld()).thenReturn(world);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("world");
executor.onCommand(
player, mock(Command.class), "questadmin",
new String[] {"createboard", "physical"}
);
assertTrue(repository.state.boards().isEmpty());
verify(player).sendMessage(
"The physical quest board needs 23 empty placement blocks."
);
}
@Test @Test
void consoleAndMissingTargetDoNotChangeState() throws Exception { void consoleAndMissingTargetDoNotChangeState() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository(); MemoryBoardRepository repository = new MemoryBoardRepository();
@@ -14,15 +14,19 @@ import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class QuestCommandTest { final class QuestCommandTest {
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z"); 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 @Test
void disabledSettingGatesEveryPlayerCommandFormAndAutocomplete() throws Exception { void disabledSettingGatesNonCreationCommandForms() throws Exception {
QuestCreationGateway creator = mock(QuestCreationGateway.class); QuestCreationGateway creator = mock(QuestCreationGateway.class);
QuestBrowser browser = mock(QuestBrowser.class); QuestBrowser browser = mock(QuestBrowser.class);
QuestCompletionGateway completer = mock(QuestCompletionGateway.class); QuestCompletionGateway completer = mock(QuestCompletionGateway.class);
@@ -43,7 +47,6 @@ final class QuestCommandTest {
List<String[]> forms = List.of( List<String[]> forms = List.of(
new String[] {}, new String[] {},
new String[] {"list"}, new String[] {"list"},
new String[] {"create", "stone", "1"},
new String[] {"complete", "quest-id"}, new String[] {"complete", "quest-id"},
new String[] {"cancel", "quest-id"}, new String[] {"cancel", "quest-id"},
new String[] {"claim"} new String[] {"claim"}
@@ -62,6 +65,97 @@ final class QuestCommandTest {
verifyNoInteractions(creator, browser, completer, canceller, claimant); 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 @Test
void routesValidatedCreateArgumentsWithCurrentUtcTime() { void routesValidatedCreateArgumentsWithCurrentUtcTime() {
RecordingCreator creator = new RecordingCreator(); RecordingCreator creator = new RecordingCreator();
@@ -238,6 +332,23 @@ final class QuestCommandTest {
).isEmpty()); ).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) { private static QuestCommand command(RecordingCreator creator) {
return command(creator, now -> List.of()); return command(creator, now -> List.of());
} }
@@ -32,6 +32,19 @@ final class QuestListingFormatterTest {
assertTrue(listing.contains("Time remaining: 1d 1h 1m 1s")); 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 @Test
void roundsAnActiveSubsecondBoundaryUpToOneSecond() { void roundsAnActiveSubsecondBoundaryUpToOneSecond() {
Quest quest = quest(NOW.plusNanos(1), List.of(new EscrowItem("DIAMOND", 1, null))); Quest quest = quest(NOW.plusNanos(1), List.of(new EscrowItem("DIAMOND", 1, null)));
@@ -3,6 +3,7 @@ package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals; 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 java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -21,6 +22,45 @@ final class YamlBoardRepositoryTest {
assertTrue(repository.load().boards().isEmpty()); assertTrue(repository.load().boards().isEmpty());
} }
@Test
void loadsExistingSingleLocationBoardsFileWithoutMigration() throws Exception {
Path path = temporaryDirectory.resolve("boards.yml");
Files.writeString(path, """
boards:
- world-id: 00000000-0000-0000-0000-000000000001
world-name: survival
x: 4
y: 70
z: -8
""");
BoardState state = new YamlBoardRepository(path).load();
assertEquals(Set.of(new RegisteredBoard(
new BoardId(UUID.fromString("00000000-0000-0000-0000-000000000001"), 4, 70, -8),
"survival"
)), state.boards());
}
@Test
void persistsAllGeneratedInteractionLocationsAcrossRegistryRestart() throws Exception {
UUID worldId = UUID.fromString("00000000-0000-0000-0000-000000000003");
PhysicalBoardPlan plan = PhysicalBoardPlan.create(
new BoardId(worldId, 10, 64, 20), BoardFacing.WEST
);
Set<RegisteredBoard> generated = plan.interactionLocations().stream()
.map(location -> new RegisteredBoard(location, "survival"))
.collect(java.util.stream.Collectors.toSet());
Path path = temporaryDirectory.resolve("boards.yml");
BoardRegistry first = new BoardRegistry(new YamlBoardRepository(path));
assertEquals(BoardRegistrationResult.CREATED, first.registerAll(generated));
BoardRegistry restarted = new BoardRegistry(new YamlBoardRepository(path));
assertEquals(17, restarted.size());
assertTrue(generated.stream().allMatch(board -> restarted.contains(board.id())));
}
@Test @Test
void roundTripsWorldIdentityAndCoordinates() throws Exception { void roundTripsWorldIdentityAndCoordinates() throws Exception {
Path path = temporaryDirectory.resolve("boards.yml"); Path path = temporaryDirectory.resolve("boards.yml");
@@ -1,8 +1,11 @@
package games.dmg.spigotquestboard; package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; 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.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@@ -12,8 +15,14 @@ import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; 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.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
final class YamlQuestRepositoryTest { final class YamlQuestRepositoryTest {
@TempDir Path temporaryDirectory; @TempDir Path temporaryDirectory;
@@ -53,6 +62,90 @@ final class YamlQuestRepositoryTest {
assertTrue(yaml.contains(itemData)); 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 @Test
void missingLifecycleStatusDefaultsToActiveForExistingYaml() throws Exception { void missingLifecycleStatusDefaultsToActiveForExistingYaml() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml"); Path path = temporaryDirectory.resolve("quests.yml");
@@ -161,6 +254,32 @@ final class YamlQuestRepositoryTest {
assertEquals(QuestClaimType.RETURNED_REWARD, claim.type()); 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 @Test
void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception { void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml"); Path path = temporaryDirectory.resolve("quests.yml");