7 Commits
Author SHA1 Message Date
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
dmg 2624c2bc02 fix(ui): fit quest board dialogs on screen
Release / release (push) Successful in 2m23s
CI / build (push) Successful in 1m7s
2026-09-05 08:52:28 -04:00
dmg 719cd49609 chore(dev): ignore local test server
CI / build (push) Successful in 1m28s
Release / release (push) Successful in 2m13s
2026-09-05 08:41:47 -04:00
dmg be13b9b87c feat(admin): control player quest commands
Release / release (push) Successful in 2m8s
CI / build (push) Successful in 1m15s
2026-09-05 08:36:45 -04:00
dmg af6d6d0827 feat(quests): add expiration and held item claims
CI / build (push) Successful in 1m6s
Release / release (push) Successful in 2m22s
2026-09-05 08:15:16 -04:00
dmg 5ca939be6c feat(quests): add issuer quest cancellation
Release / release (push) Successful in 2m22s
CI / build (push) Successful in 1m8s
2026-09-05 07:54:23 -04:00
60 changed files with 4037 additions and 122 deletions
+1
View File
@@ -1,5 +1,6 @@
.gradle/
build/
.local-server/
out/
.idea/
*.iml
+5 -4
View File
@@ -6,7 +6,7 @@ The approved behavior is specified in the [OKF knowledge bundle](knowledge/index
## Status
Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. Right-clicking any registered board opens a native dialog where a player can request a block and quantity while escrowing the exact reward stack held in their main hand. Every board and `/quests list` show the same active quests with requested blocks, rewards, issuers, and time remaining. Players can complete quests at any board or with `/quests complete <quest>` by delivering the required blocks. Exact escrowed rewards are granted immediately, and delivered blocks are held for the issuer. Claim collection remains under development.
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 centered title and browsing-instruction signs above three decorative signs; its visible panel blocks and all five signs are registered. Right-clicking any registered board opens a compact native dashboard with dedicated browsing, creation, and claim screens. A player can request a block and quantity while escrowing the exact reward stack held in their main hand. Every board and `/quests list` show the same active quests with requested blocks, rewards, issuers, and time remaining. Players can complete quests at any board or with `/quests complete <quest>` by delivering the required blocks. Exact escrowed rewards are granted immediately, and delivered blocks are held for the issuer. Issuers can cancel their own active quests at any board or with `/quests cancel <quest>`. Completed deliveries and rewards from cancelled or seven-day-expired quests are held durably and can be collected at any board or with `/quests claim`; inventory overflow drops at the claimant's feet.
## Requirements
@@ -21,9 +21,9 @@ Administrators can register persistent shared quest boards by targeting a block
The plugin JAR is written to `build/libs/`.
## Planned player commands
## Player commands
Player commands are disabled by default and can be enabled by an administrator.
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.
```text
/quests
@@ -34,12 +34,13 @@ Player commands are disabled by default and can be enabled by an administrator.
/quests claim
```
## Planned administration
## Administration
The `spigotquestboard.admin` permission is granted to server operators by default.
```text
/questadmin createboard
/questadmin createboard physical
/questadmin commands <enable|disable>
```
+44
View File
@@ -54,3 +54,47 @@ description: Chronological record of material decisions affecting Spigot Quest B
- Completion serializes state transitions, holds delivered blocks in durable issuer claims, and grants exact escrowed rewards with owner-protected overflow drops.
- Added durable online and next-login issuer notifications and rollback before persistence succeeds.
- Verified 49 tests and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-05 — Issuer quest cancellation
- Added issuer-only board actions and `/quests cancel <quest>` with ownership-filtered autocomplete.
- Cancellation atomically moves exact reward escrow into an issuer claim without directly changing inventory.
- Serialized cancellation against completion and retained active state when persistence fails.
- Verified 58 tests and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-05 — Quest expiration and held-item claims
- Added boundary-safe scheduled expiration that moves exact rewards into durable issuer claims.
- Added claim collection at every board and through `/quests claim`, distinguishing delivered blocks from cancelled or expired reward returns.
- Inventory overflow drops at the claimant's feet with ownership protection, and failed claim acknowledgement rolls inventory and drops back before retry.
- Added persisted online and next-login expiry notifications.
- Verified 73 tests and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-05 — Administrative player-command control
- Disabled all player `/quests` command forms and autocomplete by default while preserving equivalent physical-board actions.
- Added persistent `/questadmin commands enable|disable` control with permission-aware autocomplete and failure-safe updates.
- Kept administrative board creation available independently of the player-command setting.
- Verified 82 tests and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-05 — Screen-fitting quest-board dialogs
- Replaced the clipped 800-pixel combined board screen with a compact 420-pixel dashboard.
- Split browsing, creation, and claims into dedicated native dialog screens with Back navigation.
- Presented active quests as individual detail dialogs while retaining completion and issuer cancellation actions.
- Verified 88 tests, including a 250-quest navigation case, and the plugin JAR with `./gradlew clean check jar`.
## 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`.
+3
View File
@@ -14,3 +14,6 @@ description: Catalog of user stories for the Spigot Quest Board plugin.
6. [US-006: Cancel an owned quest](us-006-cancel-an-owned-quest.md)
7. [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
8. [US-008: Control player quest commands](us-008-control-player-quest-commands.md)
9. [US-009: Use a screen-fitting quest-board interface](us-009-use-a-screen-fitting-quest-board-interface.md)
10. [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.md)
11. [US-011: Add readable physical-board signage](us-011-add-readable-physical-board-signage.md)
@@ -2,7 +2,7 @@
type: User Story
title: "US-006: Cancel an owned quest"
description: Let an issuer cancel an active quest and reclaim its escrowed reward safely.
status: backlog
status: done
---
# US-006: Cancel an owned quest
@@ -11,15 +11,15 @@ As a **quest issuer**, I want to cancel my active quest so that I can reclaim th
## Acceptance criteria
- [ ] An issuer can cancel their own active quest through any registered board.
- [ ] A player cannot cancel a quest issued by another player.
- [ ] Completed, cancelled, and expired quests cannot be cancelled.
- [ ] Cancellation removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer.
- [ ] The reward is not inserted directly into the issuer's inventory during cancellation.
- [ ] When player commands are enabled, `/quests cancel <quest>` provides equivalent behavior and only autocompletes the player's cancellable quest identifiers.
- [ ] Cancellation and simultaneous completion are serialized so items cannot be duplicated or lost.
- [ ] Persistence failure leaves the quest active and its reward escrowed.
- [ ] Automated tests verify ownership, state validation, claim creation, autocomplete, rollback, and completion races.
- [x] An issuer can cancel their own active quest through any registered board.
- [x] A player cannot cancel a quest issued by another player.
- [x] Completed, cancelled, and expired quests cannot be cancelled.
- [x] Cancellation removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer.
- [x] The reward is not inserted directly into the issuer's inventory during cancellation.
- [x] When player commands are enabled, `/quests cancel <quest>` provides equivalent behavior and only autocompletes the player's cancellable quest identifiers.
- [x] Cancellation and simultaneous completion are serialized so items cannot be duplicated or lost.
- [x] Persistence failure leaves the quest active and its reward escrowed.
- [x] Automated tests verify ownership, state validation, claim creation, autocomplete, rollback, and completion races.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-007: Expire quests and claim held items"
description: Hold delivered blocks and returned rewards until their owner safely claims them.
status: backlog
status: done
---
# US-007: Expire quests and claim held items
@@ -11,18 +11,18 @@ As a **quest issuer**, I want delivered blocks and returned rewards held at the
## Acceptance criteria
- [ ] An uncompleted quest expires seven days after its creation time.
- [ ] Expiration removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer.
- [ ] Completed-quest claims contain the exact block items delivered by the completing player.
- [ ] Claims from completion, cancellation, and expiration can be collected through any registered board.
- [ ] The board clearly distinguishes delivered blocks from returned rewards.
- [ ] Online issuers receive a real-time message when items become claimable.
- [ ] Offline issuers receive a message on their next login.
- [ ] When player commands are enabled, `/quests claim` collects the same pending claims.
- [ ] Items are inserted into the claimant's inventory where space permits; overflow is dropped safely at their feet with a clear message.
- [ ] A claim is removed only after all of its items have been inserted or dropped successfully.
- [ ] Pending claims and notification state survive logout and server restart without duplication or loss.
- [ ] Automated tests verify expiry boundaries, each claim source, notifications, overflow, failure recovery, and persistence.
- [x] An uncompleted quest expires seven days after its creation time.
- [x] Expiration removes the quest from active listings and converts its exact escrowed reward into a claim for the issuer.
- [x] Completed-quest claims contain the exact block items delivered by the completing player.
- [x] Claims from completion, cancellation, and expiration can be collected through any registered board.
- [x] The board clearly distinguishes delivered blocks from returned rewards.
- [x] Online issuers receive a real-time message when items become claimable.
- [x] Offline issuers receive a message on their next login.
- [x] When player commands are enabled, `/quests claim` collects the same pending claims.
- [x] Items are inserted into the claimant's inventory where space permits; overflow is dropped safely at their feet with a clear message.
- [x] A claim is removed only after all of its items have been inserted or dropped successfully.
- [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.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-008: Control player quest commands"
description: Let administrators require physical-board interaction by controlling access to player quest commands.
status: backlog
status: done
---
# US-008: Control player quest commands
@@ -11,14 +11,14 @@ As an **administrator**, I want to enable or disable player quest commands so th
## Acceptance criteria
- [ ] Player `/quests` commands are disabled by default.
- [ ] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete.
- [ ] The command setting persists across server restarts.
- [ ] When disabled, `/quests`, `list`, `create`, `complete`, `cancel`, and `claim` reject execution with a clear message directing the player to a quest board.
- [ ] Disabling commands does not prevent any equivalent action through a registered board.
- [ ] `/questadmin` remains available to authorized administrators regardless of the player-command setting.
- [ ] Unauthorized users cannot change the setting.
- [ ] Automated tests verify the default, persistence, authorization, every gated subcommand, and autocomplete.
- [x] Player `/quests` commands are disabled by default.
- [x] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete.
- [x] The command setting persists across server restarts.
- [x] When disabled, `/quests`, `list`, `create`, `complete`, `cancel`, and `claim` reject execution with a clear message directing the player to a quest board.
- [x] Disabling commands does not prevent any equivalent action through a registered board.
- [x] `/questadmin` remains available to authorized administrators regardless of the player-command setting.
- [x] Unauthorized users cannot change the setting.
- [x] Automated tests verify the default, persistence, authorization, every gated subcommand, and autocomplete.
## Related
@@ -0,0 +1,30 @@
---
type: User Story
title: "US-009: Use a screen-fitting quest-board interface"
description: Split the oversized quest-board dialog into compact navigable screens that fit the player's display.
status: done
---
# US-009: Use a screen-fitting quest-board interface
As a **player**, I want the quest-board interface to fit on screen so that I can read quest information and reach every action.
## Acceptance criteria
- [x] The oversized combined dialog is replaced by a compact dashboard.
- [x] Dialog message bodies use a maximum width of 420 pixels.
- [x] Browsing, quest creation, and pending claims use dedicated screens rather than one combined body.
- [x] Active quests are presented as individual navigable entries instead of one unbounded text listing.
- [x] Each quest detail shows its issuer, requested material and quantity, reward, and time remaining.
- [x] Completion, issuer-only cancellation, creation, and claim collection remain available through the board.
- [x] Nested screens provide Back controls that return toward the quest-board dashboard.
- [x] Large active-quest collections remain navigable without producing an unbounded single dialog.
- [x] Automated tests verify width limits, screen separation, navigation, displayed content, and action routing.
## Related
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md)
- [US-004: Browse available quests](us-004-browse-available-quests.md)
- [US-005: Deliver blocks and complete a quest](us-005-deliver-blocks-and-complete-a-quest.md)
- [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
@@ -0,0 +1,29 @@
---
type: User Story
title: "US-010: Generate a physical quest-board structure"
description: Let an administrator generate and register a decorative oak quest board at a targeted ground anchor.
status: done
---
# US-010: Generate a physical quest-board structure
As an **administrator**, I want quest-board creation to optionally construct a recognizable physical board so that I do not need to build each board manually.
## Acceptance criteria
- [x] `/questadmin createboard physical` generates and registers a physical quest board while the existing `/questadmin createboard` behavior remains unchanged.
- [x] The targeted block is treated as the ground anchor and remains unchanged.
- [x] The generated board faces the administrator and is five blocks wide and four blocks tall.
- [x] The outer columns use oak-log pillars and the center uses oak planks.
- [x] Oak wall signs on the front display decorative obfuscated or gibberish text.
- [x] Clicking generated visible planks or signs opens the same globally shared quest-board interface.
- [x] Generation requires empty space and refuses to overwrite an existing structure.
- [x] Structure generation and interaction-location registration form one failure-safe transaction; failure restores changed blocks and registers nothing.
- [x] Generated interaction locations persist across restarts and remain compatible with existing registered boards.
- [x] The `physical` argument is offered through permission-aware command autocomplete.
- [x] Automated tests verify geometry, facing direction, obstruction handling, rollback, registration, persistence, and autocomplete.
## Related
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
- [US-009: Use a screen-fitting quest-board interface](us-009-use-a-screen-fitting-quest-board-interface.md)
@@ -0,0 +1,26 @@
---
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.
## Related
- [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.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");
};
}
}
@@ -1,6 +1,7 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
@@ -16,12 +17,22 @@ final class BoardRegistry {
}
synchronized BoardRegistrationResult register(RegisteredBoard board) throws IOException {
Objects.requireNonNull(board, "board");
if (boards.containsKey(board.id())) {
return BoardRegistrationResult.ALREADY_REGISTERED;
}
return registerAll(Set.of(Objects.requireNonNull(board, "board")));
}
synchronized BoardRegistrationResult registerAll(Collection<RegisteredBoard> additions)
throws IOException {
Objects.requireNonNull(additions, "additions");
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())));
boards = Map.copyOf(candidate);
return BoardRegistrationResult.CREATED;
@@ -35,6 +46,10 @@ final class BoardRegistry {
return boards.size();
}
synchronized Map<BoardId, RegisteredBoard> registeredBoards() {
return Map.copyOf(boards);
}
private static Map<BoardId, RegisteredBoard> index(BoardState state) throws IOException {
Map<BoardId, RegisteredBoard> indexed = new LinkedHashMap<>();
for (RegisteredBoard board : state.boards()) {
@@ -0,0 +1,122 @@
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;
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());
for (int line = 0; line < lines.size(); line++) {
sign.getSide(Side.FRONT).line(line, lines.get(line));
}
if (!sign.update(true, false)) {
throw new IllegalStateException("Could not configure physical quest-board sign");
}
}
@Override
public boolean matches(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
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;
}
List<Component> expected = signLines(placement.signKind());
for (int line = 0; line < expected.size(); line++) {
if (!expected.get(line).equals(sign.getSide(Side.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());
}
}
@@ -0,0 +1,66 @@
package games.dmg.spigotquestboard;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
final class BukkitQuestClaimInventory implements QuestClaimInventory {
@Override
public PreparedClaim prepare(List<EscrowItem> items) {
List<ItemStack> stacks = items.stream().map(EscrowItem::toItemStack).toList();
return player -> deliver(player, stacks);
}
static Delivery deliver(Player player, List<ItemStack> stacks) {
PlayerInventory inventory = player.getInventory();
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
List<Item> drops = new ArrayList<>();
int overflow = 0;
try {
for (ItemStack stack : stacks) {
Map<Integer, ItemStack> leftovers = inventory.addItem(stack.clone());
for (ItemStack leftover : leftovers.values()) {
Item drop = player.getWorld().dropItem(player.getLocation(), leftover.clone());
drops.add(drop);
drop.setOwner(player.getUniqueId());
overflow += leftover.getAmount();
}
}
} catch (RuntimeException exception) {
inventory.setStorageContents(cloneContents(snapshot));
drops.forEach(Item::remove);
throw exception;
}
int deliveredOverflow = overflow;
return new Delivery() {
private boolean rolledBack;
@Override
public int overflowAmount() {
return deliveredOverflow;
}
@Override
public void rollback() {
if (rolledBack) {
return;
}
inventory.setStorageContents(cloneContents(snapshot));
drops.forEach(Item::remove);
rolledBack = true;
}
};
}
private static ItemStack[] cloneContents(ItemStack[] contents) {
ItemStack[] copy = new ItemStack[contents.length];
for (int index = 0; index < contents.length; index++) {
copy[index] = contents[index] == null ? null : contents[index].clone();
}
return copy;
}
}
@@ -0,0 +1,4 @@
package games.dmg.spigotquestboard;
record ClaimCollectionResult(int claimsCollected, int overflowItems) {
}
@@ -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,188 @@
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)) {
if (!hasLegacyRegistrations(candidate, registered)) {
continue;
}
PhysicalBoardWorld world = worlds.open(candidate.anchor().worldId());
if (world != null && isLegacyBoard(candidate, world)
&& upgrade(candidate, world)) {
upgraded++;
registered = registry.registeredBoards();
}
}
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 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 upgrade(Candidate candidate, PhysicalBoardWorld world) throws IOException {
List<PhysicalBoardPlan.Placement> signs = newSigns(
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 static List<PhysicalBoardPlan.Placement> newSigns(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,15 @@
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;
}
void restore(BoardId location, Object snapshot);
}
@@ -0,0 +1,23 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.util.Objects;
final class PlayerCommandSettings {
private final PlayerCommandSettingsRepository repository;
private boolean enabled;
PlayerCommandSettings(PlayerCommandSettingsRepository repository) throws IOException {
this.repository = Objects.requireNonNull(repository, "repository");
enabled = repository.loadEnabled();
}
boolean enabled() {
return enabled;
}
void setEnabled(boolean enabled) throws IOException {
repository.saveEnabled(enabled);
this.enabled = enabled;
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
interface PlayerCommandSettingsRepository {
boolean loadEnabled() throws IOException;
void saveEnabled(boolean enabled) throws IOException;
}
@@ -1,19 +1,40 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import org.bukkit.block.Block;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
final class QuestAdminCommand implements CommandExecutor {
final class QuestAdminCommand implements CommandExecutor, TabCompleter {
private static final String PERMISSION = "spigotquestboard.admin";
private final BoardRegistry registry;
private final PlayerCommandSettings playerCommands;
private final PhysicalBoardCreator physicalBoards;
QuestAdminCommand(BoardRegistry registry) {
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.playerCommands = Objects.requireNonNull(playerCommands, "playerCommands");
this.physicalBoards = Objects.requireNonNull(physicalBoards, "physicalBoards");
}
@Override
@@ -24,18 +45,56 @@ final class QuestAdminCommand implements CommandExecutor {
sender.sendMessage("You do not have permission to administer quest boards.");
return true;
}
if (arguments.length != 1 || !"createboard".equalsIgnoreCase(arguments[0])) {
sender.sendMessage("Usage: /questadmin createboard");
if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) {
return updatePlayerCommands(sender, arguments[1]);
}
if (arguments.length == 1 && "createboard".equalsIgnoreCase(arguments[0])) {
createBoard(sender);
return true;
}
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;
}
private boolean updatePlayerCommands(CommandSender sender, String action) {
final boolean enabled;
if ("enable".equalsIgnoreCase(action)) {
enabled = true;
} else if ("disable".equalsIgnoreCase(action)) {
enabled = false;
} else {
sender.sendMessage("Usage: /questadmin commands enable|disable");
return true;
}
try {
playerCommands.setEnabled(enabled);
sender.sendMessage("Player quest commands " + (enabled ? "enabled." : "disabled."));
} catch (IOException exception) {
sender.sendMessage(
"Player command setting could not be saved. Player quest commands remain "
+ (playerCommands.enabled() ? "enabled." : "disabled.")
);
}
return true;
}
private void createBoard(CommandSender sender) {
if (!(sender instanceof Player player)) {
sender.sendMessage("A player must target the quest board block.");
return true;
return;
}
Block target = player.getTargetBlockExact(5);
if (target == null) {
sender.sendMessage("Target a physical block within five blocks.");
return true;
return;
}
try {
BoardRegistrationResult result = registry.register(RegisteredBoard.from(target));
@@ -45,6 +104,61 @@ final class QuestAdminCommand implements CommandExecutor {
} catch (IOException exception) {
sender.sendMessage("The quest board could not be saved. No board was created.");
}
return true;
}
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
public List<String> onTabComplete(
CommandSender sender, Command command, String alias, String[] arguments
) {
if (!sender.hasPermission(PERMISSION)) {
return List.of();
}
if (arguments.length == 1) {
return startsWith(List.of("createboard", "commands"), arguments[0]);
}
if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) {
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();
}
private static List<String> startsWith(List<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.stream()
.filter(candidate -> candidate.startsWith(normalized))
.toList();
}
}
@@ -0,0 +1,87 @@
package games.dmg.spigotquestboard;
import java.util.List;
import java.util.Objects;
/** A server-independent description of the quest-board dialog hierarchy. */
record QuestBoardDialogSpec(
int maximumBodyWidth,
Screen dashboard,
Screen browse,
Screen create,
Screen claims,
List<QuestEntry> questEntries
) {
QuestBoardDialogSpec {
if (maximumBodyWidth <= 0) {
throw new IllegalArgumentException("Maximum body width must be positive");
}
Objects.requireNonNull(dashboard, "dashboard");
Objects.requireNonNull(browse, "browse");
Objects.requireNonNull(create, "create");
Objects.requireNonNull(claims, "claims");
questEntries = List.copyOf(Objects.requireNonNull(questEntries, "questEntries"));
}
List<String> dashboardActions() {
return dashboard.actions().stream().map(Action::label).toList();
}
record Screen(
String title,
String externalTitle,
String message,
List<Action> actions,
List<Input> inputs,
boolean hasBackAction
) {
Screen {
Objects.requireNonNull(title, "title");
Objects.requireNonNull(externalTitle, "externalTitle");
Objects.requireNonNull(message, "message");
actions = List.copyOf(Objects.requireNonNull(actions, "actions"));
inputs = List.copyOf(Objects.requireNonNull(inputs, "inputs"));
}
}
record QuestEntry(
String title,
String message,
List<Action> actions,
boolean hasBackAction
) {
QuestEntry {
Objects.requireNonNull(title, "title");
Objects.requireNonNull(message, "message");
actions = List.copyOf(Objects.requireNonNull(actions, "actions"));
}
}
record Action(String label, Route route, String questId) {
Action {
Objects.requireNonNull(label, "label");
Objects.requireNonNull(route, "route");
}
}
record Input(String key, String label, String initial, int maximumLength) {
Input {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(label, "label");
Objects.requireNonNull(initial, "initial");
if (maximumLength <= 0) {
throw new IllegalArgumentException("Maximum input length must be positive");
}
}
}
enum Route {
OPEN_BROWSE,
OPEN_CREATE,
OPEN_CLAIMS,
SUBMIT_CREATE,
COMPLETE,
CANCEL,
COLLECT
}
}
@@ -1,16 +1,17 @@
package games.dmg.spigotquestboard;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.registry.RegistryKey;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.input.DialogInput;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import io.papermc.paper.registry.set.RegistrySet;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import net.kyori.adventure.text.Component;
@@ -18,13 +19,21 @@ import net.kyori.adventure.text.event.ClickCallback;
import org.bukkit.entity.Player;
final class QuestBoardDialogUi implements QuestBoardUi {
private static final int DIALOG_WIDTH = 420;
private static final ClickCallback.Options CALLBACK_OPTIONS = ClickCallback.Options.builder()
.uses(1)
.lifetime(Duration.ofMinutes(10))
.build();
private final QuestCreationGateway creator;
private final QuestBrowser browser;
private final QuestCompletionGateway completer;
private final QuestCancellationGateway canceller;
private final QuestClaimGateway claimant;
private final Clock clock;
QuestBoardDialogUi(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this(creator, browser, null, clock);
this(creator, browser, null, null, null, clock);
}
QuestBoardDialogUi(
@@ -32,18 +41,163 @@ final class QuestBoardDialogUi implements QuestBoardUi {
QuestBrowser browser,
QuestCompletionGateway completer,
Clock clock
) {
this(creator, browser, completer, null, null, clock);
}
QuestBoardDialogUi(
QuestCreationGateway creator,
QuestBrowser browser,
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
Clock clock
) {
this(creator, browser, completer, canceller, null, clock);
}
QuestBoardDialogUi(
QuestCreationGateway creator,
QuestBrowser browser,
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
QuestClaimGateway claimant,
Clock clock
) {
this.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser");
this.completer = completer;
this.canceller = canceller;
this.claimant = claimant;
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public void open(Player player) {
Objects.requireNonNull(player, "player");
player.showDialog(renderDashboard(specification(player), player));
}
QuestBoardDialogSpec specification(Player player) {
Objects.requireNonNull(player, "player");
java.time.Instant now = clock.instant();
List<QuestBoardDialogSpec.QuestEntry> entries = browser.activeQuests(now).stream()
.map(quest -> questEntry(quest, player, now))
.toList();
List<QuestBoardDialogSpec.Action> dashboardActions = List.of(
action("Browse quests", QuestBoardDialogSpec.Route.OPEN_BROWSE),
action("Create quest", QuestBoardDialogSpec.Route.OPEN_CREATE),
action("Pending claims", QuestBoardDialogSpec.Route.OPEN_CLAIMS)
);
QuestBoardDialogSpec.Screen dashboard = new QuestBoardDialogSpec.Screen(
"Quest Board",
"Quest Board",
"Choose what you want to do.",
dashboardActions,
List.of(),
false
);
QuestBoardDialogSpec.Screen browse = new QuestBoardDialogSpec.Screen(
"Browse Quests",
"Browse quests",
entries.isEmpty()
? "No active quests."
: "Select a quest to view its request, reward, issuer, and time remaining.",
List.of(),
List.of(),
true
);
QuestBoardDialogSpec.Screen create = new QuestBoardDialogSpec.Screen(
"Create Quest",
"Create quest",
"Hold the reward in your main hand. The entire exact stack, including all item "
+ "metadata, is escrowed only if the quest saves successfully.",
List.of(action("Create quest", QuestBoardDialogSpec.Route.SUBMIT_CREATE)),
List.of(
new QuestBoardDialogSpec.Input("requested_material", "Requested block", "", 64),
new QuestBoardDialogSpec.Input("requested_quantity", "Quantity", "64", 10)
),
true
);
QuestBoardDialogSpec.Screen claims = new QuestBoardDialogSpec.Screen(
"Pending Claims",
"Pending claims",
claimListingText(player),
claimant == null
? List.of()
: List.of(action("Collect pending claims", QuestBoardDialogSpec.Route.COLLECT)),
List.of(),
true
);
return new QuestBoardDialogSpec(
DIALOG_WIDTH, dashboard, browse, create, claims, entries
);
}
private QuestBoardDialogSpec.QuestEntry questEntry(
Quest quest,
Player player,
java.time.Instant now
) {
List<QuestBoardDialogSpec.Action> actions = new java.util.ArrayList<>();
if (completer != null) {
actions.add(new QuestBoardDialogSpec.Action(
"Complete quest", QuestBoardDialogSpec.Route.COMPLETE, quest.id().toString()
));
}
if (canceller != null && quest.issuerId().equals(player.getUniqueId())) {
actions.add(new QuestBoardDialogSpec.Action(
"Cancel quest", QuestBoardDialogSpec.Route.CANCEL, quest.id().toString()
));
}
return new QuestBoardDialogSpec.QuestEntry(
quest.requestedAmount() + " × " + quest.requestedMaterial(),
QuestListingFormatter.format(quest, now),
actions,
true
);
}
private static QuestBoardDialogSpec.Action action(
String label,
QuestBoardDialogSpec.Route route
) {
return new QuestBoardDialogSpec.Action(label, route, null);
}
private Dialog renderDashboard(QuestBoardDialogSpec specification, Player player) {
List<Dialog> screens = List.of(
renderBrowse(specification, player),
renderCreate(specification, player),
renderClaims(specification, player)
);
return dialog(
specification.dashboard(),
DialogType.dialogList(
RegistrySet.valueSet(RegistryKey.DIALOG, screens), null, 1, 260
)
);
}
private Dialog renderBrowse(QuestBoardDialogSpec specification, Player player) {
ActionButton back = backToDashboard(player);
if (specification.questEntries().isEmpty()) {
return dialog(specification.browse(), DialogType.notice(back));
}
List<Dialog> quests = specification.questEntries().stream()
.map(entry -> renderQuest(entry, player))
.toList();
return dialog(
specification.browse(),
DialogType.dialogList(
RegistrySet.valueSet(RegistryKey.DIALOG, quests), back, 1, 260
)
);
}
private Dialog renderCreate(QuestBoardDialogSpec specification, Player player) {
ActionButton create = ActionButton.builder(Component.text("Create quest"))
.tooltip(Component.text("Escrow your held stack and publish this quest"))
.width(150)
.width(180)
.action(DialogAction.customClick((response, audience) -> {
if (audience instanceof Player respondingPlayer) {
submit(
@@ -52,45 +206,111 @@ final class QuestBoardDialogUi implements QuestBoardUi {
response.getText("requested_quantity")
);
}
}, ClickCallback.Options.builder()
.uses(1)
.lifetime(Duration.ofMinutes(10))
.build()))
}, CALLBACK_OPTIONS))
.build();
java.time.Instant now = clock.instant();
List<Quest> activeQuests = browser.activeQuests(now);
String listing = QuestListingFormatter.formatAll(activeQuests, now);
List<ActionButton> actions = new ArrayList<>();
actions.add(create);
if (completer != null) {
for (Quest quest : activeQuests) {
actions.add(completionButton(quest));
}
return dialog(
specification.create(),
DialogType.multiAction(List.of(create), backToDashboard(player), 1)
);
}
private Dialog renderClaims(QuestBoardDialogSpec specification, Player player) {
ActionButton back = backToDashboard(player);
if (claimant == null) {
return dialog(specification.claims(), DialogType.notice(back));
}
DialogBase base = DialogBase.builder(Component.text("Quest Board"))
.externalTitle(Component.text("Quest Board — Active quests and create"))
.body(List.of(DialogBody.plainMessage(Component.text(
"ACTIVE QUESTS\n" + listing + "\n\nCREATE A QUEST\n"
+ "Hold the reward in your main hand. The entire exact stack, including all item metadata, "
+ "will be removed and held in escrow only if this quest saves successfully."
), 800)))
.inputs(List.of(
DialogInput.text("requested_material", Component.text("Requested block"))
.initial("")
.maxLength(64)
.build(),
DialogInput.text("requested_quantity", Component.text("Quantity"))
.initial("64")
.maxLength(10)
.build()
))
ActionButton collect = callbackButton(
"Collect pending claims",
"Collect delivered blocks and returned rewards",
250,
respondingPlayer -> submitClaim(respondingPlayer)
);
return dialog(
specification.claims(), DialogType.multiAction(List.of(collect), back, 1)
);
}
private Dialog renderQuest(QuestBoardDialogSpec.QuestEntry entry, Player player) {
QuestBoardDialogSpec.Screen screen = new QuestBoardDialogSpec.Screen(
entry.title(), entry.title(), entry.message(), entry.actions(), List.of(), true
);
ActionButton back = backToBrowse(player);
List<ActionButton> actions = entry.actions().stream()
.map(action -> renderQuestAction(action))
.toList();
return dialog(
screen,
actions.isEmpty() ? DialogType.notice(back) : DialogType.multiAction(actions, back, 1)
);
}
private ActionButton renderQuestAction(QuestBoardDialogSpec.Action action) {
return switch (action.route()) {
case COMPLETE -> callbackButton(
action.label(), "Deliver the requested blocks", 180,
player -> submitCompletion(player, action.questId())
);
case CANCEL -> callbackButton(
action.label(), "Return the escrowed reward to pending claims", 180,
player -> submitCancellation(player, action.questId())
);
default -> throw new IllegalArgumentException("Unsupported quest action route");
};
}
private Dialog dialog(QuestBoardDialogSpec.Screen screen, DialogType type) {
DialogBase.Builder base = DialogBase.builder(Component.text(screen.title()))
.externalTitle(Component.text(screen.externalTitle()))
.body(List.of(DialogBody.plainMessage(Component.text(screen.message()), DIALOG_WIDTH)))
.canCloseWithEscape(true)
.pause(false)
.afterAction(DialogBase.DialogAfterAction.CLOSE)
.afterAction(DialogBase.DialogAfterAction.CLOSE);
if (!screen.inputs().isEmpty()) {
base.inputs(screen.inputs().stream().map(input -> DialogInput.text(
input.key(), Component.text(input.label())
)
.initial(input.initial())
.maxLength(input.maximumLength())
.build()).toList());
}
DialogBase builtBase = base.build();
return Dialog.create(factory -> factory.empty().base(builtBase).type(type));
}
private ActionButton backToDashboard(Player player) {
return callbackButton(
"Back", "Return to the quest-board dashboard", 120,
respondingPlayer -> respondingPlayer.showDialog(
renderDashboard(specification(respondingPlayer), respondingPlayer)
)
);
}
private ActionButton backToBrowse(Player player) {
return callbackButton(
"Back", "Return to active quests", 120,
respondingPlayer -> {
QuestBoardDialogSpec current = specification(respondingPlayer);
respondingPlayer.showDialog(renderBrowse(current, respondingPlayer));
}
);
}
private ActionButton callbackButton(
String label,
String tooltip,
int width,
java.util.function.Consumer<Player> callback
) {
return ActionButton.builder(Component.text(label))
.tooltip(Component.text(tooltip))
.width(width)
.action(DialogAction.customClick((response, audience) -> {
if (audience instanceof Player respondingPlayer) {
callback.accept(respondingPlayer);
}
}, CALLBACK_OPTIONS))
.build();
player.showDialog(Dialog.create(factory -> factory.empty()
.base(base)
.type(DialogType.multiAction(actions).columns(1).build())));
}
String listingText() {
@@ -98,22 +318,57 @@ final class QuestBoardDialogUi implements QuestBoardUi {
return QuestListingFormatter.formatAll(browser.activeQuests(now), now);
}
private ActionButton completionButton(Quest quest) {
String id = quest.id().toString();
return ActionButton.builder(Component.text(
"Complete " + quest.requestedAmount() + " " + quest.requestedMaterial()
))
.tooltip(Component.text("Deliver blocks for quest " + id))
.width(250)
.action(DialogAction.customClick((response, audience) -> {
if (audience instanceof Player respondingPlayer) {
submitCompletion(respondingPlayer, id);
}
}, ClickCallback.Options.builder()
.uses(1)
.lifetime(Duration.ofMinutes(10))
.build()))
.build();
String claimListingText(Player player) {
if (claimant == null) {
return "Claim collection is unavailable.";
}
List<QuestClaim> claims = claimant.pendingClaims(player);
if (claims.isEmpty()) {
return "No pending claims.";
}
return claims.stream().map(claim -> {
String kind = claim.type() == QuestClaimType.DELIVERED_BLOCKS
? "DELIVERED BLOCKS" : "RETURNED REWARD (" + claim.source().name() + ")";
String items = claim.items().stream()
.map(item -> item.amount() + " " + item.material())
.reduce((left, right) -> left + ", " + right)
.orElseThrow();
return kind + "" + items + " — quest " + claim.questId();
}).reduce((left, right) -> left + "\n" + right).orElseThrow();
}
List<String> cancellableQuestIds(Player player) {
return browser.cancellableQuestIds(player.getUniqueId(), clock.instant());
}
void submitCancellation(Player player, String questId) {
if (canceller == null) {
player.sendMessage("Quest cancellation is unavailable.");
return;
}
try {
canceller.cancel(player, questId, clock.instant());
} catch (IllegalArgumentException | IllegalStateException exception) {
player.sendMessage(exception.getMessage());
} catch (IOException exception) {
player.sendMessage(
"The quest could not be saved. It remains active and its reward remains escrowed."
);
}
}
void submitClaim(Player player) {
if (claimant == null) {
player.sendMessage("Quest claims are unavailable.");
return;
}
try {
claimant.collect(player);
} catch (IOException | RuntimeException exception) {
player.sendMessage(
"Your claim could not be collected safely. It remains pending; please try again."
);
}
}
void submitCompletion(Player player, String questId) {
@@ -0,0 +1,31 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.entity.Player;
final class QuestCancellationController implements QuestCancellationGateway {
private final QuestService quests;
QuestCancellationController(QuestService quests) {
this.quests = Objects.requireNonNull(quests, "quests");
}
@Override
public QuestClaim cancel(Player player, String questId, Instant cancelledAt) throws IOException {
Objects.requireNonNull(player, "player");
final UUID id;
try {
id = UUID.fromString(Objects.requireNonNull(questId, "questId"));
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("Quest identifier must be a valid UUID.", exception);
}
QuestClaim claim = quests.cancel(id, player.getUniqueId(), cancelledAt);
player.sendMessage(
"Quest " + id + " cancelled. Your exact escrowed reward is ready to claim at a quest board."
);
return claim;
}
}
@@ -0,0 +1,9 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.time.Instant;
import org.bukkit.entity.Player;
interface QuestCancellationGateway {
QuestClaim cancel(Player player, String questId, Instant cancelledAt) throws IOException;
}
@@ -10,7 +10,8 @@ record QuestClaim(
UUID questId,
UUID ownerId,
List<EscrowItem> items,
Instant createdAt
Instant createdAt,
QuestClaimSource source
) {
QuestClaim {
Objects.requireNonNull(id, "id");
@@ -21,5 +22,17 @@ record QuestClaim(
throw new IllegalArgumentException("Claim items must not be empty");
}
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(source, "source");
}
QuestClaim(
UUID id, UUID questId, UUID ownerId, List<EscrowItem> items, Instant createdAt
) {
this(id, questId, ownerId, items, createdAt, QuestClaimSource.COMPLETION);
}
QuestClaimType type() {
return source == QuestClaimSource.COMPLETION
? QuestClaimType.DELIVERED_BLOCKS : QuestClaimType.RETURNED_REWARD;
}
}
@@ -0,0 +1,64 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import org.bukkit.entity.Player;
final class QuestClaimController implements QuestClaimGateway {
private final QuestService quests;
private final QuestClaimInventory inventory;
QuestClaimController(QuestService quests, QuestClaimInventory inventory) {
this.quests = Objects.requireNonNull(quests, "quests");
this.inventory = Objects.requireNonNull(inventory, "inventory");
}
@Override
public List<QuestClaim> pendingClaims(Player player) {
Objects.requireNonNull(player, "player");
return quests.claimsFor(player.getUniqueId());
}
@Override
public synchronized ClaimCollectionResult collect(Player player) throws IOException {
Objects.requireNonNull(player, "player");
List<QuestClaim> claims = quests.claimsFor(player.getUniqueId());
if (claims.isEmpty()) {
player.sendMessage("You have no pending quest claims.");
return new ClaimCollectionResult(0, 0);
}
int collected = 0;
int overflow = 0;
for (QuestClaim claim : claims) {
QuestClaimInventory.PreparedClaim prepared = inventory.prepare(claim.items());
QuestClaimInventory.Delivery delivery = prepared.deliver(player);
try {
quests.acknowledgeClaim(player.getUniqueId(), claim.id());
} catch (IOException | RuntimeException exception) {
delivery.rollback();
throw exception;
}
collected++;
overflow += delivery.overflowAmount();
player.sendMessage(description(claim));
}
player.sendMessage("Collected " + collected + " quest claim(s).");
if (overflow > 0) {
player.sendMessage(
overflow + " item(s) did not fit and were dropped at your feet, protected for you."
);
}
return new ClaimCollectionResult(collected, overflow);
}
private static String description(QuestClaim claim) {
if (claim.type() == QuestClaimType.DELIVERED_BLOCKS) {
return "Collected delivered blocks from completed quest " + claim.questId() + ".";
}
String reason = claim.source() == QuestClaimSource.EXPIRATION
? "expired" : "cancelled";
return "Collected returned reward from " + reason + " quest " + claim.questId() + ".";
}
}
@@ -0,0 +1,13 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.util.List;
import org.bukkit.entity.Player;
interface QuestClaimGateway {
ClaimCollectionResult collect(Player player) throws IOException;
default List<QuestClaim> pendingClaims(Player player) {
return List.of();
}
}
@@ -0,0 +1,17 @@
package games.dmg.spigotquestboard;
import java.util.List;
import org.bukkit.entity.Player;
interface QuestClaimInventory {
PreparedClaim prepare(List<EscrowItem> items);
interface PreparedClaim {
Delivery deliver(Player player);
}
interface Delivery {
int overflowAmount();
void rollback();
}
}
@@ -0,0 +1,7 @@
package games.dmg.spigotquestboard;
enum QuestClaimSource {
COMPLETION,
CANCELLATION,
EXPIRATION
}
@@ -0,0 +1,6 @@
package games.dmg.spigotquestboard;
enum QuestClaimType {
DELIVERED_BLOCKS,
RETURNED_REWARD
}
@@ -17,10 +17,13 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
private final QuestCreationGateway creator;
private final QuestBrowser browser;
private final QuestCompletionGateway completer;
private final QuestCancellationGateway canceller;
private final QuestClaimGateway claimant;
private final Clock clock;
private final PlayerCommandSettings playerCommands;
QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this(creator, browser, null, clock);
this(creator, browser, null, null, null, clock);
}
QuestCommand(
@@ -28,23 +31,83 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestBrowser browser,
QuestCompletionGateway completer,
Clock clock
) {
this(creator, browser, completer, null, null, clock);
}
QuestCommand(
QuestCreationGateway creator,
QuestBrowser browser,
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
Clock clock
) {
this(creator, browser, completer, canceller, null, clock);
}
QuestCommand(
QuestCreationGateway creator,
QuestBrowser browser,
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
QuestClaimGateway claimant,
Clock clock
) {
this(creator, browser, completer, canceller, claimant, clock, null);
}
QuestCommand(
QuestCreationGateway creator,
QuestBrowser browser,
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
QuestClaimGateway claimant,
Clock clock,
PlayerCommandSettings playerCommands
) {
this.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser");
this.completer = completer;
this.canceller = canceller;
this.claimant = claimant;
this.clock = Objects.requireNonNull(clock, "clock");
this.playerCommands = playerCommands;
}
@Override
public boolean onCommand(
CommandSender sender, Command command, String label, String[] arguments
) {
if (!playerCommandsEnabled()) {
sender.sendMessage(
"Player quest commands are disabled. Use a physical quest board instead."
);
return true;
}
if (arguments.length == 0
|| (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) {
Instant now = clock.instant();
sender.sendMessage(QuestListingFormatter.formatAll(browser.activeQuests(now), now));
return true;
}
if (arguments.length == 1 && "claim".equalsIgnoreCase(arguments[0])) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can collect quest claims.");
return true;
}
if (claimant == null) {
sender.sendMessage("Quest claims are unavailable.");
return true;
}
try {
claimant.collect(player);
} catch (IOException | RuntimeException exception) {
sender.sendMessage(
"Your claim could not be collected safely. It remains pending; please try again."
);
}
return true;
}
if (arguments.length == 2 && "complete".equalsIgnoreCase(arguments[0])) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can complete quests with inventory items.");
@@ -65,6 +128,26 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
}
return true;
}
if (arguments.length == 2 && "cancel".equalsIgnoreCase(arguments[0])) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can cancel quests.");
return true;
}
if (canceller == null) {
sender.sendMessage("Quest cancellation is unavailable.");
return true;
}
try {
canceller.cancel(player, arguments[1], clock.instant());
} catch (IllegalArgumentException | IllegalStateException exception) {
sender.sendMessage(exception.getMessage());
} catch (IOException exception) {
sender.sendMessage(
"The quest could not be saved. It remains active and its reward remains escrowed."
);
}
return true;
}
if (arguments.length != 3 || !"create".equalsIgnoreCase(arguments[0])) {
usage(sender);
return true;
@@ -95,11 +178,13 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
public List<String> onTabComplete(
CommandSender sender, Command command, String alias, String[] arguments
) {
if (!(sender instanceof Player)) {
if (!playerCommandsEnabled() || !(sender instanceof Player)) {
return List.of();
}
if (arguments.length == 1) {
return startsWith(List.of("create", "list", "complete"), arguments[0]);
return startsWith(
List.of("create", "list", "complete", "cancel", "claim"), arguments[0]
);
}
if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) {
return creator.suggestBlockMaterials(arguments[1]);
@@ -119,6 +204,10 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
return List.of();
}
private boolean playerCommandsEnabled() {
return playerCommands == null || playerCommands.enabled();
}
private static List<String> startsWith(List<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.stream()
@@ -129,7 +218,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
private static void usage(CommandSender sender) {
sender.sendMessage(
"Usage: /quests [list] | /quests create <block> <quantity> | "
+ "/quests complete <quest>"
+ "/quests complete <quest> | /quests cancel <quest> | /quests claim"
);
sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed.");
}
@@ -0,0 +1,39 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.time.Clock;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
final class QuestExpiryTask implements Runnable {
private final QuestService quests;
private final IssuerNotifier notifier;
private final Clock clock;
private final Logger logger;
QuestExpiryTask(
QuestService quests, IssuerNotifier notifier, Clock clock, Logger logger
) {
this.quests = quests;
this.notifier = notifier;
this.clock = clock;
this.logger = logger;
}
@Override
public void run() {
final List<QuestClaim> claims;
try {
claims = quests.expire(clock.instant());
} catch (IOException exception) {
logger.log(Level.WARNING, "Could not persist expired quests; expiration will be retried", exception);
return;
}
Set<java.util.UUID> owners = new LinkedHashSet<>();
claims.forEach(claim -> owners.add(claim.ownerId()));
owners.forEach(notifier::notifyIfOnline);
}
}
@@ -84,7 +84,8 @@ final class QuestService implements QuestBrowser {
QuestStatus.COMPLETED
);
QuestClaim claim = new QuestClaim(
UUID.randomUUID(), active.id(), active.issuerId(), delivery, completedAt
UUID.randomUUID(), active.id(), active.issuerId(), delivery, completedAt,
QuestClaimSource.COMPLETION
);
IssuerNotification notification = new IssuerNotification(
UUID.randomUUID(), active.id(), active.issuerId(),
@@ -107,6 +108,103 @@ final class QuestService implements QuestBrowser {
return new QuestCompletion(completed, claim, completed.reward());
}
synchronized QuestClaim cancel(UUID questId, UUID issuerId, Instant cancelledAt)
throws IOException {
Objects.requireNonNull(questId, "questId");
Objects.requireNonNull(issuerId, "issuerId");
Objects.requireNonNull(cancelledAt, "cancelledAt");
Quest active = state.quests().get(questId);
if (active == null) {
throw new IllegalArgumentException("Quest not found: " + questId);
}
if (!active.issuerId().equals(issuerId)) {
throw new IllegalArgumentException("Only the quest issuer can cancel this quest.");
}
if (active.status() != QuestStatus.ACTIVE || !cancelledAt.isBefore(active.expiresAt())) {
throw new IllegalStateException("That quest is no longer active or has expired.");
}
Quest cancelled = new Quest(
active.id(), active.issuerId(), active.issuerName(), active.requestedMaterial(),
active.requestedAmount(), active.reward(), active.createdAt(), active.expiresAt(),
QuestStatus.CANCELLED
);
QuestClaim claim = new QuestClaim(
UUID.randomUUID(), active.id(), active.issuerId(), active.reward(), cancelledAt,
QuestClaimSource.CANCELLATION
);
Map<UUID, Quest> quests = new LinkedHashMap<>(state.quests());
quests.put(active.id(), cancelled);
Map<UUID, List<QuestClaim>> claims = new LinkedHashMap<>(state.claims());
List<QuestClaim> ownerClaims = new ArrayList<>(
claims.getOrDefault(active.issuerId(), List.of())
);
ownerClaims.add(claim);
claims.put(active.issuerId(), ownerClaims);
save(new QuestState(quests, claims, state.notifications()));
return claim;
}
synchronized List<QuestClaim> expire(Instant now) throws IOException {
Objects.requireNonNull(now, "now");
Map<UUID, Quest> quests = new LinkedHashMap<>(state.quests());
Map<UUID, List<QuestClaim>> claims = mutableClaims(state.claims());
Map<UUID, IssuerNotification> notifications = new LinkedHashMap<>(state.notifications());
List<QuestClaim> expiredClaims = new ArrayList<>();
for (Quest active : state.quests().values()) {
if (active.status() != QuestStatus.ACTIVE || active.expiresAt().isAfter(now)) {
continue;
}
Quest expired = new Quest(
active.id(), active.issuerId(), active.issuerName(), active.requestedMaterial(),
active.requestedAmount(), active.reward(), active.createdAt(), active.expiresAt(),
QuestStatus.EXPIRED
);
QuestClaim claim = new QuestClaim(
UUID.randomUUID(), active.id(), active.issuerId(), active.reward(), active.expiresAt(),
QuestClaimSource.EXPIRATION
);
IssuerNotification notification = new IssuerNotification(
UUID.randomUUID(), active.id(), active.issuerId(),
"Quest " + active.id() + " expired. Your returned reward can be claimed "
+ "at any quest board or with /quests claim.",
active.expiresAt()
);
quests.put(active.id(), expired);
claims.computeIfAbsent(active.issuerId(), ignored -> new ArrayList<>()).add(claim);
notifications.put(notification.id(), notification);
expiredClaims.add(claim);
}
if (!expiredClaims.isEmpty()) {
save(new QuestState(quests, claims, notifications));
}
return List.copyOf(expiredClaims);
}
synchronized List<QuestClaim> claimsFor(UUID ownerId) {
Objects.requireNonNull(ownerId, "ownerId");
return state.claims().getOrDefault(ownerId, List.of());
}
synchronized void acknowledgeClaim(UUID ownerId, UUID claimId) throws IOException {
Objects.requireNonNull(ownerId, "ownerId");
Objects.requireNonNull(claimId, "claimId");
List<QuestClaim> existing = state.claims().getOrDefault(ownerId, List.of());
if (existing.stream().noneMatch(claim -> claim.id().equals(claimId))) {
return;
}
Map<UUID, List<QuestClaim>> claims = mutableClaims(state.claims());
List<QuestClaim> remaining = new ArrayList<>(claims.get(ownerId));
remaining.removeIf(claim -> claim.id().equals(claimId));
if (remaining.isEmpty()) {
claims.remove(ownerId);
} else {
claims.put(ownerId, remaining);
}
save(new QuestState(state.quests(), claims, state.notifications()));
}
synchronized List<IssuerNotification> pendingNotifications(UUID recipientId) {
Objects.requireNonNull(recipientId, "recipientId");
return state.notifications().values().stream()
@@ -136,6 +234,14 @@ final class QuestService implements QuestBrowser {
return state;
}
private static Map<UUID, List<QuestClaim>> mutableClaims(
Map<UUID, List<QuestClaim>> source
) {
Map<UUID, List<QuestClaim>> copy = new LinkedHashMap<>();
source.forEach((owner, claims) -> copy.put(owner, new ArrayList<>(claims)));
return copy;
}
private void save(QuestState candidate) throws IOException {
repository.save(candidate);
state = candidate;
@@ -12,22 +12,30 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
public void onEnable() {
final BoardRegistry boards;
final QuestService quests;
final PlayerCommandSettings playerCommands;
final int upgradedBoards;
try {
boards = new BoardRegistry(new YamlBoardRepository(
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(
getDataFolder().toPath().resolve("quests.yml")
));
playerCommands = new PlayerCommandSettings(
new YamlPlayerCommandSettingsRepository(
getDataFolder().toPath().resolve("settings.yml")
)
);
} catch (IOException exception) {
getLogger().log(Level.SEVERE, "Could not load quest board state", exception);
getLogger().log(Level.SEVERE, "Could not load plugin state", exception);
getServer().getPluginManager().disablePlugin(this);
return;
}
QuestAdminCommand admin = new QuestAdminCommand(boards);
command("questadmin").setExecutor(admin);
QuestCreationGateway creator = new QuestCreationController(
quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory()
);
@@ -38,22 +46,65 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
QuestCompletionGateway completer = new QuestCompletionController(
quests, new BukkitQuestCompletionInventory(), notifier
);
QuestCommand questCommand = new QuestCommand(creator, quests, completer, clock);
command("quests").setExecutor(questCommand);
command("quests").setTabCompleter(questCommand);
QuestCancellationGateway canceller = new QuestCancellationController(quests);
QuestClaimGateway claimant = new QuestClaimController(
quests, new BukkitQuestClaimInventory()
);
CommandHandlers handlers = commandHandlers(
boards, playerCommands, creator, quests, completer, canceller, claimant, clock
);
command("questadmin").setExecutor(handlers.admin());
command("questadmin").setTabCompleter(handlers.admin());
command("quests").setExecutor(handlers.quests());
command("quests").setTabCompleter(handlers.quests());
getServer().getPluginManager().registerEvents(notifier, this);
getServer().getPluginManager().registerEvents(
new QuestBoardInteractionListener(
boards, new QuestBoardDialogUi(creator, quests, completer, clock)
boards, new QuestBoardDialogUi(
creator, quests, completer, canceller, claimant, clock
)
),
this
);
getServer().getScheduler().runTaskTimer(
this,
new QuestExpiryTask(quests, notifier, clock, getLogger()),
0L,
20L
);
getLogger().info(
"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(
BoardRegistry boards,
PlayerCommandSettings playerCommands,
QuestCreationGateway creator,
QuestBrowser browser,
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
QuestClaimGateway claimant,
Clock clock
) {
return new CommandHandlers(
new QuestAdminCommand(boards, playerCommands),
new QuestCommand(
creator, browser, completer, canceller, claimant, clock, playerCommands
)
);
}
record CommandHandlers(QuestAdminCommand admin, QuestCommand quests) { }
private PluginCommand command(String name) {
return Objects.requireNonNull(getCommand(name), "Missing command metadata for " + name);
}
@@ -0,0 +1,53 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
final class YamlPlayerCommandSettingsRepository implements PlayerCommandSettingsRepository {
private static final String ENABLED_PATH = "player-commands.enabled";
private final Path path;
YamlPlayerCommandSettingsRepository(Path path) {
this.path = Objects.requireNonNull(path, "path");
}
@Override
public boolean loadEnabled() throws IOException {
if (!Files.exists(path)) {
return false;
}
YamlConfiguration yaml = new YamlConfiguration();
try {
yaml.load(path.toFile());
} catch (InvalidConfigurationException exception) {
throw new IOException("Invalid player command settings", exception);
}
return yaml.getBoolean(ENABLED_PATH, false);
}
@Override
public void saveEnabled(boolean enabled) throws IOException {
YamlConfiguration yaml = new YamlConfiguration();
yaml.set(ENABLED_PATH, enabled);
Path parent = path.toAbsolutePath().getParent();
Files.createDirectories(parent);
Path temporary = Files.createTempFile(parent, path.getFileName().toString(), ".tmp");
try {
yaml.save(temporary.toFile());
try {
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
}
@@ -60,7 +60,10 @@ final class YamlQuestRepository implements QuestRepository {
UUID.fromString(requiredString(entry, "quest-id")),
UUID.fromString(requiredString(entry, "owner-id")),
readItems(entry.get("items"), "claim items"),
Instant.parse(requiredString(entry, "created-at"))
Instant.parse(requiredString(entry, "created-at")),
readClaimSource(entry.get("source"), quests.get(
UUID.fromString(requiredString(entry, "quest-id"))
))
);
claims.computeIfAbsent(claim.ownerId(), ignored -> new ArrayList<>()).add(claim);
}
@@ -111,6 +114,7 @@ final class YamlQuestRepository implements QuestRepository {
serialized.put("quest-id", claim.questId().toString());
serialized.put("owner-id", claim.ownerId().toString());
serialized.put("created-at", claim.createdAt().toString());
serialized.put("source", claim.source().name());
serialized.put("items", writeItems(claim.items()));
serializedClaims.add(serialized);
}
@@ -182,6 +186,20 @@ final class YamlQuestRepository implements QuestRepository {
return serializedItems;
}
private static QuestClaimSource readClaimSource(Object value, Quest quest) {
if (value instanceof String source) {
return QuestClaimSource.valueOf(source);
}
if (value != null) {
throw new IllegalArgumentException("Invalid claim source");
}
if (quest == null || quest.status() == QuestStatus.COMPLETED) {
return QuestClaimSource.COMPLETION;
}
return quest.status() == QuestStatus.EXPIRED
? QuestClaimSource.EXPIRATION : QuestClaimSource.CANCELLATION;
}
private static QuestStatus readStatus(Object value) {
if (value == null) {
return QuestStatus.ACTIVE;
+1 -1
View File
@@ -10,7 +10,7 @@ commands:
usage: /quests [list|create|complete|cancel|claim]
questadmin:
description: Administer Spigot Quest Board.
usage: /questadmin <createboard|commands>
usage: /questadmin <createboard [physical]|commands>
permission: spigotquestboard.admin
permissions:
spigotquestboard.admin:
@@ -26,6 +26,40 @@ final class BoardRegistryTest {
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
void duplicateDoesNotWrite() throws IOException {
RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD)));
@@ -37,6 +37,29 @@ final class BukkitIssuerNotifierTest {
assertTrue(repository.state.notifications().isEmpty());
}
@Test
void failedNotificationAcknowledgementRemainsPendingForRetry() throws Exception {
Repository repository = new Repository();
QuestService service = completedService(repository);
IssuerNotification notification = service.state().notifications().values().iterator().next();
Server server = mock(Server.class);
Player player = mock(Player.class);
when(server.getPlayer(notification.recipientId())).thenReturn(player);
when(player.isOnline()).thenReturn(true);
when(player.getUniqueId()).thenReturn(notification.recipientId());
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
service, server, Logger.getAnonymousLogger()
);
repository.fail = true;
notifier.notifyIfOnline(notification.recipientId());
assertEquals(1, service.pendingNotifications(notification.recipientId()).size());
repository.fail = false;
notifier.notifyIfOnline(notification.recipientId());
assertTrue(service.pendingNotifications(notification.recipientId()).isEmpty());
}
@Test
void offlineNotificationRemainsDurableUntilNextLogin() throws Exception {
Repository repository = new Repository();
@@ -73,7 +96,14 @@ final class BukkitIssuerNotifierTest {
private static final class Repository implements QuestRepository {
private QuestState state = QuestState.empty();
private boolean fail;
@Override public QuestState load() { return state; }
@Override public void save(QuestState candidate) { state = candidate; }
@Override
public void save(QuestState candidate) throws java.io.IOException {
if (fail) {
throw new java.io.IOException("disk full");
}
state = candidate;
}
}
}
@@ -0,0 +1,144 @@
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 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,94 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class BukkitQuestClaimInventoryTest {
@Test
void inventoryOverflowIsDroppedAtFeetWithOwnerProtection() {
UUID owner = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
World world = mock(World.class);
Location feet = mock(Location.class);
Item dropped = mock(Item.class);
ItemStack requested = mock(ItemStack.class);
ItemStack requestedCopy = mock(ItemStack.class);
ItemStack leftover = mock(ItemStack.class);
ItemStack leftoverCopy = mock(ItemStack.class);
when(requested.clone()).thenReturn(requestedCopy);
when(leftover.clone()).thenReturn(leftoverCopy);
when(leftover.getAmount()).thenReturn(2);
when(player.getUniqueId()).thenReturn(owner);
when(player.getInventory()).thenReturn(inventory);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(feet);
when(inventory.getStorageContents()).thenReturn(new ItemStack[36]);
HashMap<Integer, ItemStack> leftovers = new HashMap<>();
leftovers.put(0, leftover);
when(inventory.addItem(any(ItemStack.class))).thenReturn(leftovers);
when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(dropped);
QuestClaimInventory.Delivery delivery = BukkitQuestClaimInventory.deliver(
player, List.of(requested)
);
assertEquals(2, delivery.overflowAmount());
verify(world).dropItem(feet, leftoverCopy);
verify(dropped).setOwner(owner);
}
@Test
void ownerProtectionFailureRestoresInventoryAndRemovesPartialDrop() {
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
World world = mock(World.class);
Location feet = mock(Location.class);
Item dropped = mock(Item.class);
ItemStack requested = mock(ItemStack.class);
ItemStack requestedCopy = mock(ItemStack.class);
ItemStack leftover = mock(ItemStack.class);
ItemStack leftoverCopy = mock(ItemStack.class);
ItemStack existing = mock(ItemStack.class);
ItemStack existingCopy = mock(ItemStack.class);
when(requested.clone()).thenReturn(requestedCopy);
when(leftover.clone()).thenReturn(leftoverCopy);
when(existing.clone()).thenReturn(existingCopy);
when(existingCopy.clone()).thenReturn(existingCopy);
ItemStack[] original = new ItemStack[] {existing};
when(player.getInventory()).thenReturn(inventory);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(feet);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(inventory.getStorageContents()).thenReturn(original);
HashMap<Integer, ItemStack> leftovers = new HashMap<>();
leftovers.put(0, leftover);
when(inventory.addItem(requestedCopy)).thenReturn(leftovers);
when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(dropped);
doThrow(new IllegalStateException("owner rejected")).when(dropped).setOwner(any());
assertThrows(
IllegalStateException.class,
() -> BukkitQuestClaimInventory.deliver(player, List.of(requested))
);
verify(inventory).setStorageContents(any(ItemStack[].class));
verify(dropped).remove();
}
}
@@ -0,0 +1,163 @@
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")
));
}
@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,206 @@
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 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 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 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 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 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 final Map<BoardId, PhysicalBoardPlan.Placement> existing = new HashMap<>();
private final Map<BoardId, BoardFacing> facings = 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 existing.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);
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 void restore(BoardId location, Object snapshot) {
if (snapshot == null) {
existing.remove(location);
facings.remove(location);
} else {
PhysicalBoardPlan.Placement placement = (PhysicalBoardPlan.Placement) snapshot;
existing.put(location, placement);
}
}
}
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;
}
}
}
@@ -0,0 +1,52 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class PlayerCommandSettingsTest {
@TempDir Path temporaryDirectory;
@Test
void defaultsDisabledAndPersistsEnabledState() throws Exception {
Path path = temporaryDirectory.resolve("settings.yml");
PlayerCommandSettings settings = new PlayerCommandSettings(
new YamlPlayerCommandSettingsRepository(path)
);
assertFalse(settings.enabled());
settings.setEnabled(true);
PlayerCommandSettings reloaded = new PlayerCommandSettings(
new YamlPlayerCommandSettingsRepository(path)
);
assertTrue(reloaded.enabled());
reloaded.setEnabled(false);
assertFalse(new PlayerCommandSettings(
new YamlPlayerCommandSettingsRepository(path)
).enabled());
}
@Test
void persistenceFailureLeavesPriorState() throws Exception {
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return false; }
@Override public void saveEnabled(boolean enabled) throws IOException {
throw new IOException("disk full");
}
}
);
assertThrows(IOException.class, () -> settings.setEnabled(true));
assertFalse(settings.enabled());
}
}
@@ -0,0 +1,86 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
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 adminAndPlayerCommandsShareThePersistedSetting() throws Exception {
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
private boolean enabled;
@Override public boolean loadEnabled() { return enabled; }
@Override public void saveEnabled(boolean enabled) { this.enabled = enabled; }
}
);
BoardRepository boards = new BoardRepository() {
@Override public BoardState load() { return new BoardState(Set.of()); }
@Override public void save(BoardState state) { }
};
QuestBrowser browser = mock(QuestBrowser.class);
when(browser.activeQuests(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
SpigotQuestBoardPlugin.CommandHandlers handlers =
SpigotQuestBoardPlugin.commandHandlers(
new BoardRegistry(boards), settings, mock(QuestCreationGateway.class), browser,
null, null, null, Clock.systemUTC()
);
Player player = mock(Player.class);
CommandSender admin = mock(CommandSender.class);
when(admin.hasPermission("spigotquestboard.admin")).thenReturn(true);
Command command = mock(Command.class);
assertTrue(handlers.quests().onCommand(
player, command, "quests", new String[] {"list"}
));
verify(player).sendMessage(
"Player quest commands are disabled. Use a physical quest board instead."
);
handlers.admin().onCommand(
admin, command, "questadmin", new String[] {"commands", "enable"}
);
handlers.quests().onCommand(player, command, "quests", new String[] {"list"});
verify(browser).activeQuests(org.mockito.ArgumentMatchers.any());
}
}
@@ -1,5 +1,6 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
@@ -7,9 +8,13 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.World;
import org.bukkit.block.BlockFace;
import org.bukkit.block.Block;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
@@ -20,7 +25,10 @@ final class QuestAdminCommandTest {
@Test
void unauthorizedSenderCannotCreateBoard() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository();
QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository));
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(repository),
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
);
Player player = mock(Player.class);
when(player.hasPermission("spigotquestboard.admin")).thenReturn(false);
@@ -30,10 +38,114 @@ final class QuestAdminCommandTest {
assertTrue(repository.state.boards().isEmpty());
}
@Test
void authorizedSenderCanEnableAndDisablePlayerCommands() throws Exception {
MemoryPlayerCommandSettingsRepository settingsRepository =
new MemoryPlayerCommandSettingsRepository();
PlayerCommandSettings settings = new PlayerCommandSettings(settingsRepository);
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(new MemoryBoardRepository()), settings
);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotquestboard.admin")).thenReturn(true);
assertTrue(executor.onCommand(
sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"}
));
assertTrue(settings.enabled());
assertTrue(executor.onCommand(
sender, mock(Command.class), "questadmin", new String[] {"commands", "disable"}
));
assertFalse(settings.enabled());
verify(sender).sendMessage("Player quest commands enabled.");
verify(sender).sendMessage("Player quest commands disabled.");
}
@Test
void unauthorizedSenderCannotChangePlayerCommands() throws Exception {
MemoryPlayerCommandSettingsRepository repository =
new MemoryPlayerCommandSettingsRepository();
PlayerCommandSettings settings = new PlayerCommandSettings(repository);
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(new MemoryBoardRepository()), settings
);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotquestboard.admin")).thenReturn(false);
executor.onCommand(
sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"}
);
assertFalse(settings.enabled());
assertEquals(0, repository.saves);
}
@Test
void settingsPersistenceFailureIsReportedAndLeavesPriorState() throws Exception {
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return false; }
@Override public void saveEnabled(boolean enabled) throws IOException {
throw new IOException("disk full");
}
}
);
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(new MemoryBoardRepository()), settings
);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotquestboard.admin")).thenReturn(true);
executor.onCommand(
sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"}
);
assertFalse(settings.enabled());
verify(sender).sendMessage(
"Player command setting could not be saved. Player quest commands remain disabled."
);
}
@Test
void autocompleteIsPermissionAwareAndContextual() throws Exception {
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(new MemoryBoardRepository()),
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
);
CommandSender authorized = mock(CommandSender.class);
when(authorized.hasPermission("spigotquestboard.admin")).thenReturn(true);
CommandSender unauthorized = mock(CommandSender.class);
when(unauthorized.hasPermission("spigotquestboard.admin")).thenReturn(false);
Command command = mock(Command.class);
assertEquals(List.of("commands"), executor.onTabComplete(
authorized, command, "questadmin", new String[] {"com"}
));
assertEquals(List.of("enable"), executor.onTabComplete(
authorized, command, "questadmin", new String[] {"commands", "en"}
));
assertEquals(List.of("disable"), executor.onTabComplete(
authorized, command, "questadmin", new String[] {"commands", "di"}
));
assertEquals(List.of("physical"), executor.onTabComplete(
authorized, command, "questadmin", new String[] {"createboard", ""}
));
assertEquals(List.of("physical"), executor.onTabComplete(
authorized, command, "questadmin", new String[] {"createboard", "phy"}
));
assertTrue(executor.onTabComplete(
unauthorized, command, "questadmin", new String[] {""}
).isEmpty());
}
@Test
void registersTargetedPhysicalBlock() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository();
QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository));
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(repository),
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
);
Player player = mock(Player.class);
Block block = mock(Block.class);
World world = mock(World.class);
@@ -54,10 +166,102 @@ 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
void consoleAndMissingTargetDoNotChangeState() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository();
QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository));
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(repository),
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
);
CommandSender console = mock(CommandSender.class);
when(console.hasPermission("spigotquestboard.admin")).thenReturn(true);
assertTrue(executor.onCommand(console, mock(Command.class), "questadmin", new String[] {"createboard"}));
@@ -70,6 +274,18 @@ final class QuestAdminCommandTest {
assertTrue(repository.state.boards().isEmpty());
}
private static final class MemoryPlayerCommandSettingsRepository
implements PlayerCommandSettingsRepository {
private boolean enabled;
private int saves;
@Override public boolean loadEnabled() { return enabled; }
@Override public void saveEnabled(boolean enabled) {
this.enabled = enabled;
saves++;
}
}
private static final class MemoryBoardRepository implements BoardRepository {
private BoardState state = new BoardState(Set.of());
@Override public BoardState load() { return state; }
@@ -1,8 +1,11 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.Clock;
@@ -16,6 +19,180 @@ import org.junit.jupiter.api.Test;
final class QuestBoardDialogUiTest {
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
@Test
void dashboardUsesCompactDedicatedNavigableScreens() {
RecordingCreator creator = new RecordingCreator(false);
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(creator.quest(NOW)), Clock.fixed(NOW, ZoneOffset.UTC)
);
QuestBoardDialogSpec specification = ui.specification(mock(Player.class));
assertEquals(420, specification.maximumBodyWidth());
assertEquals(List.of("Browse quests", "Create quest", "Pending claims"),
specification.dashboardActions());
assertEquals(1, specification.questEntries().size());
org.junit.jupiter.api.Assertions.assertTrue(
specification.questEntries().getFirst().hasBackAction()
);
}
@Test
void dedicatedScreensKeepContentSeparateAndProvideBackNavigation() {
RecordingCreator creator = new RecordingCreator(false);
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(creator.quest(NOW)), null, null, player ->
new ClaimCollectionResult(0, 0), Clock.fixed(NOW, ZoneOffset.UTC)
);
QuestBoardDialogSpec specification = ui.specification(mock(Player.class));
assertEquals("Choose what you want to do.", specification.dashboard().message());
assertTrue(specification.browse().message().contains("Select a quest"));
assertTrue(specification.create().message().contains("reward in your main hand"));
assertEquals("No pending claims.", specification.claims().message());
assertFalse(specification.dashboard().hasBackAction());
assertTrue(specification.browse().hasBackAction());
assertTrue(specification.create().hasBackAction());
assertTrue(specification.claims().hasBackAction());
assertTrue(specification.questEntries().stream().allMatch(
QuestBoardDialogSpec.QuestEntry::hasBackAction
));
}
@Test
void questDetailsShowRequiredContentAndRouteAvailableActions() {
RecordingCreator creator = new RecordingCreator(false);
Quest quest = creator.quest(NOW);
Player issuer = mock(Player.class);
when(issuer.getUniqueId()).thenReturn(quest.issuerId());
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(quest), new RecordingCompleter(), new RecordingCanceller(),
null, Clock.fixed(NOW, ZoneOffset.UTC)
);
QuestBoardDialogSpec specification = ui.specification(issuer);
QuestBoardDialogSpec.QuestEntry entry = specification.questEntries().getFirst();
assertTrue(entry.message().contains("64 × STONE"));
assertTrue(entry.message().contains("Reward: 1 × DIAMOND"));
assertTrue(entry.message().contains("Issuer: Issuer"));
assertTrue(entry.message().contains("Time remaining: 7d"));
assertEquals(
List.of(QuestBoardDialogSpec.Route.COMPLETE, QuestBoardDialogSpec.Route.CANCEL),
entry.actions().stream().map(QuestBoardDialogSpec.Action::route).toList()
);
assertTrue(entry.actions().stream().allMatch(
action -> quest.id().toString().equals(action.questId())
));
}
@Test
void dashboardCreationAndClaimsDeclareTheirActionRoutesAndInputs() {
RecordingCreator creator = new RecordingCreator(false);
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(), null, null, player ->
new ClaimCollectionResult(0, 0), Clock.fixed(NOW, ZoneOffset.UTC)
);
QuestBoardDialogSpec specification = ui.specification(mock(Player.class));
assertEquals(
List.of(
QuestBoardDialogSpec.Route.OPEN_BROWSE,
QuestBoardDialogSpec.Route.OPEN_CREATE,
QuestBoardDialogSpec.Route.OPEN_CLAIMS
),
specification.dashboard().actions().stream()
.map(QuestBoardDialogSpec.Action::route)
.toList()
);
assertEquals(List.of("requested_material", "requested_quantity"),
specification.create().inputs().stream()
.map(QuestBoardDialogSpec.Input::key)
.toList());
assertEquals(QuestBoardDialogSpec.Route.SUBMIT_CREATE,
specification.create().actions().getFirst().route());
assertEquals(QuestBoardDialogSpec.Route.COLLECT,
specification.claims().actions().getFirst().route());
}
@Test
void largeQuestCollectionsRemainIndividualNavigableEntries() {
RecordingCreator creator = new RecordingCreator(false);
List<Quest> quests = java.util.stream.IntStream.range(0, 250)
.mapToObj(index -> new Quest(
new UUID(0, index + 100L), new UUID(0, index + 1L), "Issuer " + index,
"STONE", index + 1, List.of(new EscrowItem("DIAMOND", 1, null)),
NOW, NOW.plusSeconds(604800)
))
.toList();
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> quests, Clock.fixed(NOW, ZoneOffset.UTC)
);
QuestBoardDialogSpec specification = ui.specification(mock(Player.class));
assertEquals(250, specification.questEntries().size());
assertTrue(specification.questEntries().stream().allMatch(
QuestBoardDialogSpec.QuestEntry::hasBackAction
));
assertFalse(specification.browse().message().contains(quests.getLast().id().toString()));
}
@Test
void nonIssuersCannotReceiveCancellationRoutes() {
RecordingCreator creator = new RecordingCreator(false);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(creator.quest(NOW)), new RecordingCompleter(),
new RecordingCanceller(), Clock.fixed(NOW, ZoneOffset.UTC)
);
QuestBoardDialogSpec.QuestEntry entry = ui.specification(player)
.questEntries().getFirst();
assertEquals(List.of(QuestBoardDialogSpec.Route.COMPLETE),
entry.actions().stream().map(QuestBoardDialogSpec.Action::route).toList());
}
@Test
void disabledPlayerCommandsDoNotGateBoardUiGateways() throws Exception {
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return false; }
@Override public void saveEnabled(boolean enabled) { }
}
);
RecordingCreator creator = new RecordingCreator(false);
RecordingCompleter completer = new RecordingCompleter();
RecordingCanceller canceller = new RecordingCanceller();
java.util.concurrent.atomic.AtomicReference<Player> claimantPlayer =
new java.util.concurrent.atomic.AtomicReference<>();
QuestClaimGateway claimant = player -> {
claimantPlayer.set(player);
return new ClaimCollectionResult(0, 0);
};
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(creator.quest(NOW)), completer, canceller, claimant,
Clock.fixed(NOW, ZoneOffset.UTC)
);
Player player = mock(Player.class);
ui.listingText();
ui.submit(player, "stone", "1");
ui.submitCompletion(player, "complete-id");
ui.submitCancellation(player, "cancel-id");
ui.submitClaim(player);
org.junit.jupiter.api.Assertions.assertFalse(settings.enabled());
assertEquals("stone", creator.material);
assertEquals(player, completer.player);
assertEquals(player, canceller.player);
assertEquals(player, claimantPlayer.get());
}
@Test
void dialogSubmissionUsesEquivalentCreationFlow() {
RecordingCreator creator = new RecordingCreator(false);
@@ -52,6 +229,102 @@ final class QuestBoardDialogUiTest {
assertEquals(NOW, completer.completedAt);
}
@Test
void claimActionUsesEquivalentCollectionFlowAtABoard() {
RecordingCreator creator = new RecordingCreator(false);
java.util.concurrent.atomic.AtomicReference<Player> claimantPlayer =
new java.util.concurrent.atomic.AtomicReference<>();
QuestClaimGateway claimant = player -> {
claimantPlayer.set(player);
return new ClaimCollectionResult(1, 0);
};
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(), null, null, claimant,
Clock.fixed(NOW, ZoneOffset.UTC)
);
Player player = mock(Player.class);
ui.submitClaim(player);
assertEquals(player, claimantPlayer.get());
}
@Test
void cancellationActionUsesEquivalentCancellationFlow() {
RecordingCreator creator = new RecordingCreator(false);
RecordingCanceller canceller = new RecordingCanceller();
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(), null, canceller, Clock.fixed(NOW, ZoneOffset.UTC)
);
Player player = mock(Player.class);
String id = UUID.randomUUID().toString();
ui.submitCancellation(player, id);
assertEquals(player, canceller.player);
assertEquals(id, canceller.questId);
assertEquals(NOW, canceller.cancelledAt);
}
@Test
void boardListingDistinguishesDeliveredBlocksFromReturnedRewards() {
RecordingCreator creator = new RecordingCreator(false);
Player player = mock(Player.class);
UUID owner = UUID.randomUUID();
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(owner);
QuestClaimGateway claimant = new QuestClaimGateway() {
@Override
public ClaimCollectionResult collect(Player ignored) {
return new ClaimCollectionResult(0, 0);
}
@Override
public List<QuestClaim> pendingClaims(Player ignored) {
return List.of(
new QuestClaim(
UUID.randomUUID(), UUID.randomUUID(), owner,
List.of(new EscrowItem("STONE", 2, null)), NOW,
QuestClaimSource.COMPLETION
),
new QuestClaim(
UUID.randomUUID(), UUID.randomUUID(), owner,
List.of(new EscrowItem("DIAMOND", 1, null)), NOW,
QuestClaimSource.EXPIRATION
)
);
}
};
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(), null, null, claimant,
Clock.fixed(NOW, ZoneOffset.UTC)
);
String listing = ui.claimListingText(player);
org.junit.jupiter.api.Assertions.assertTrue(listing.contains("DELIVERED BLOCKS — 2 STONE"));
org.junit.jupiter.api.Assertions.assertTrue(
listing.contains("RETURNED REWARD (EXPIRATION) — 1 DIAMOND")
);
}
@Test
void boardOnlyOffersCancellationActionsForPlayersOwnedActiveQuests() {
RecordingCreator creator = new RecordingCreator(false);
Quest owned = creator.quest(NOW);
Quest other = new Quest(
UUID.randomUUID(), UUID.randomUUID(), "Other", "DIRT", 1,
List.of(new EscrowItem("COAL", 1, null)), NOW, NOW.plusSeconds(604800)
);
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(owned, other), null, new RecordingCanceller(),
Clock.fixed(NOW, ZoneOffset.UTC)
);
Player issuer = mock(Player.class);
org.mockito.Mockito.when(issuer.getUniqueId()).thenReturn(owned.issuerId());
assertEquals(List.of(owned.id().toString()), ui.cancellableQuestIds(issuer));
}
@Test
void everyOpeningBuildsAListingFromTheCurrentActiveQuests() {
RecordingCreator creator = new RecordingCreator(false);
@@ -77,6 +350,20 @@ final class QuestBoardDialogUiTest {
);
}
private static final class RecordingCanceller implements QuestCancellationGateway {
private Player player;
private String questId;
private Instant cancelledAt;
@Override
public QuestClaim cancel(Player cancellingPlayer, String id, Instant instant) {
player = cancellingPlayer;
questId = id;
cancelledAt = instant;
return null;
}
}
private static final class RecordingCompleter implements QuestCompletionGateway {
private Player player;
private String questId;
@@ -0,0 +1,58 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class QuestCancellationControllerTest {
@Test
void cancelsAsPlayerIdentityAndCreatesClaimWithoutInventorySettlement() throws Exception {
Repository repository = new Repository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
Quest quest = service.create(
issuer, "Issuer", "STONE", 1,
List.of(new EscrowItem("DIAMOND", 2, "exact-data")), Instant.EPOCH
);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(issuer);
QuestClaim claim = new QuestCancellationController(service).cancel(
player, quest.id().toString(), Instant.EPOCH.plusSeconds(1)
);
assertEquals(quest.reward(), claim.items());
assertEquals(List.of(claim), service.state().claims().get(issuer));
verify(player).sendMessage(contains("claim"));
}
@Test
void rejectsMalformedQuestIdentifier() throws Exception {
QuestCancellationController controller = new QuestCancellationController(
new QuestService(new Repository())
);
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> controller.cancel(mock(Player.class), "not-a-uuid", Instant.EPOCH)
);
assertEquals("Quest identifier must be a valid UUID.", exception.getMessage());
}
private static final class Repository implements QuestRepository {
private QuestState state = QuestState.empty();
@Override public QuestState load() { return state; }
@Override public void save(QuestState candidate) throws IOException { state = candidate; }
}
}
@@ -0,0 +1,159 @@
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.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
final class QuestCancellationServiceTest {
@Test
void onlyIssuerCanCancelAndExactRewardBecomesClaim() throws Exception {
Repository repository = new Repository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
EscrowItem reward = new EscrowItem("DIAMOND", 3, "exact-item-data");
Quest quest = service.create(
issuer, "Issuer", "STONE", 64, List.of(reward), Instant.EPOCH
);
assertThrows(IllegalArgumentException.class, () -> service.cancel(
quest.id(), UUID.randomUUID(), Instant.EPOCH.plusSeconds(1)
));
QuestClaim claim = service.cancel(
quest.id(), issuer, Instant.EPOCH.plusSeconds(1)
);
assertEquals(QuestStatus.CANCELLED, service.state().quests().get(quest.id()).status());
assertEquals(List.of(reward), claim.items());
assertEquals(List.of(claim), service.state().claims().get(issuer));
assertTrue(service.activeQuests(Instant.EPOCH.plusSeconds(1)).isEmpty());
}
@Test
void completedCancelledAndExpiredQuestsCannotBeCancelled() throws Exception {
Repository repository = new Repository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
Quest completed = quest(service, issuer);
service.complete(
completed.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1)
);
Quest cancelled = quest(service, issuer);
service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1));
Quest expired = quest(service, issuer);
assertThrows(IllegalStateException.class, () -> service.cancel(
completed.id(), issuer, Instant.EPOCH.plusSeconds(2)
));
assertThrows(IllegalStateException.class, () -> service.cancel(
cancelled.id(), issuer, Instant.EPOCH.plusSeconds(2)
));
assertThrows(IllegalStateException.class, () -> service.cancel(
expired.id(), issuer, expired.expiresAt()
));
}
@Test
void persistenceFailureLeavesQuestActiveAndRewardEscrowed() throws Exception {
Repository repository = new Repository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
Quest quest = quest(service, issuer);
QuestState before = service.state();
repository.fail = true;
assertThrows(IOException.class, () -> service.cancel(
quest.id(), issuer, Instant.EPOCH.plusSeconds(1)
));
assertEquals(before, service.state());
assertEquals(before, repository.state);
assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status());
assertTrue(service.state().claims().isEmpty());
}
@Test
void simultaneousCancellationAndCompletionSettleEscrowExactlyOnce() throws Exception {
Repository repository = new Repository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
Quest quest = quest(service, issuer);
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
List<Future<Boolean>> attempts = new ArrayList<>();
attempts.add(executor.submit(() -> {
start.await();
try {
service.cancel(quest.id(), issuer, Instant.EPOCH.plusSeconds(1));
return true;
} catch (IllegalStateException exception) {
return false;
}
}));
attempts.add(executor.submit(() -> {
start.await();
try {
service.complete(
quest.id(), new EscrowItem("STONE", 1, null),
Instant.EPOCH.plusSeconds(1)
);
return true;
} catch (IllegalStateException exception) {
return false;
}
}));
start.countDown();
assertEquals(1, attempts.stream().filter(attempt -> {
try {
return attempt.get();
} catch (Exception exception) {
throw new AssertionError(exception);
}
}).count());
}
QuestStatus status = service.state().quests().get(quest.id()).status();
assertTrue(status == QuestStatus.CANCELLED || status == QuestStatus.COMPLETED);
assertEquals(1, service.state().claims().get(issuer).size());
QuestClaim settlement = service.state().claims().get(issuer).getFirst();
if (status == QuestStatus.CANCELLED) {
assertEquals(quest.reward(), settlement.items());
assertTrue(service.state().notifications().isEmpty());
} else {
assertEquals(List.of(new EscrowItem("STONE", 1, null)), settlement.items());
assertEquals(1, service.state().notifications().size());
}
}
private static Quest quest(QuestService service, UUID issuer) throws IOException {
return service.create(
issuer, "Issuer", "STONE", 1,
List.of(new EscrowItem("DIAMOND", 2, null)), Instant.EPOCH
);
}
private static final class Repository implements QuestRepository {
private QuestState state = QuestState.empty();
private boolean fail;
@Override public QuestState load() { return state; }
@Override
public void save(QuestState candidate) throws IOException {
if (fail) {
throw new IOException("disk full");
}
state = candidate;
}
}
}
@@ -0,0 +1,133 @@
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.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class QuestClaimControllerTest {
@Test
void collectsEveryClaimAndClearlyDistinguishesItemsAndOverflow() throws Exception {
Repository repository = new Repository();
QuestService service = serviceWithAllClaimSources(repository);
UUID issuer = service.state().claims().keySet().iterator().next();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(issuer);
RecordingInventory inventory = new RecordingInventory(2);
ClaimCollectionResult result = new QuestClaimController(service, inventory).collect(player);
assertEquals(new ClaimCollectionResult(3, 6), result);
assertTrue(service.claimsFor(issuer).isEmpty());
verify(player).sendMessage(contains("delivered blocks from completed quest"));
verify(player).sendMessage(contains("returned reward from cancelled quest"));
verify(player).sendMessage(contains("returned reward from expired quest"));
verify(player).sendMessage(contains("dropped at your feet, protected for you"));
}
@Test
void deliveryFailureLeavesClaimPendingForRetry() throws Exception {
Repository repository = new Repository();
QuestService service = expiredService(repository);
UUID issuer = service.state().claims().keySet().iterator().next();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(issuer);
QuestClaimInventory inventory = items -> ignored -> {
throw new IllegalStateException("world rejected drop");
};
assertThrows(
IllegalStateException.class,
() -> new QuestClaimController(service, inventory).collect(player)
);
assertEquals(1, service.claimsFor(issuer).size());
}
@Test
void acknowledgementFailureRollsBackDeliveryAndLeavesClaimPending() throws Exception {
Repository repository = new Repository();
QuestService service = expiredService(repository);
UUID issuer = service.state().claims().keySet().iterator().next();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(issuer);
RecordingInventory inventory = new RecordingInventory(0);
repository.fail = true;
assertThrows(
IOException.class,
() -> new QuestClaimController(service, inventory).collect(player)
);
assertEquals(1, inventory.rollbacks);
assertEquals(1, service.claimsFor(issuer).size());
}
private static QuestService serviceWithAllClaimSources(Repository repository) throws Exception {
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
Quest completed = create(service, issuer, "STONE");
Quest cancelled = create(service, issuer, "DIRT");
Quest expired = create(service, issuer, "SAND");
service.complete(
completed.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1)
);
service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1));
service.expire(expired.expiresAt());
return service;
}
private static QuestService expiredService(Repository repository) throws Exception {
QuestService service = new QuestService(repository);
Quest quest = create(service, UUID.randomUUID(), "STONE");
service.expire(quest.expiresAt());
return service;
}
private static Quest create(QuestService service, UUID issuer, String material)
throws IOException {
return service.create(
issuer, "Issuer", material, 1,
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
);
}
private static final class RecordingInventory implements QuestClaimInventory {
private final int overflow;
private int rollbacks;
private RecordingInventory(int overflow) {
this.overflow = overflow;
}
@Override
public PreparedClaim prepare(List<EscrowItem> items) {
return player -> new Delivery() {
@Override public int overflowAmount() { return overflow; }
@Override public void rollback() { rollbacks++; }
};
}
}
private static final class Repository implements QuestRepository {
private QuestState state = QuestState.empty();
private boolean fail;
@Override public QuestState load() { return state; }
@Override public void save(QuestState candidate) throws IOException {
if (fail) {
throw new IOException("disk full");
}
state = candidate;
}
}
}
@@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Clock;
@@ -20,6 +21,47 @@ import org.junit.jupiter.api.Test;
final class QuestCommandTest {
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
@Test
void disabledSettingGatesEveryPlayerCommandFormAndAutocomplete() throws Exception {
QuestCreationGateway creator = mock(QuestCreationGateway.class);
QuestBrowser browser = mock(QuestBrowser.class);
QuestCompletionGateway completer = mock(QuestCompletionGateway.class);
QuestCancellationGateway canceller = mock(QuestCancellationGateway.class);
QuestClaimGateway claimant = mock(QuestClaimGateway.class);
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return false; }
@Override public void saveEnabled(boolean enabled) { }
}
);
QuestCommand executor = new QuestCommand(
creator, browser, completer, canceller, claimant,
Clock.fixed(NOW, ZoneOffset.UTC), settings
);
Player player = mock(Player.class);
Command command = mock(Command.class);
List<String[]> forms = List.of(
new String[] {},
new String[] {"list"},
new String[] {"create", "stone", "1"},
new String[] {"complete", "quest-id"},
new String[] {"cancel", "quest-id"},
new String[] {"claim"}
);
for (String[] form : forms) {
assertTrue(executor.onCommand(player, command, "quests", form));
}
verify(player, times(forms.size())).sendMessage(
"Player quest commands are disabled. Use a physical quest board instead."
);
assertTrue(executor.onTabComplete(
player, command, "quests", new String[] {""}
).isEmpty());
verifyNoInteractions(creator, browser, completer, canceller, claimant);
}
@Test
void routesValidatedCreateArgumentsWithCurrentUtcTime() {
RecordingCreator creator = new RecordingCreator();
@@ -105,6 +147,47 @@ final class QuestCommandTest {
assertEquals(NOW, completer.completedAt);
}
@Test
void claimCommandUsesTheSameCollectionGateway() {
RecordingCreator creator = new RecordingCreator();
Player player = mock(Player.class);
java.util.concurrent.atomic.AtomicReference<Player> claimantPlayer =
new java.util.concurrent.atomic.AtomicReference<>();
QuestClaimGateway claimant = claimingPlayer -> {
claimantPlayer.set(claimingPlayer);
return new ClaimCollectionResult(1, 0);
};
QuestCommand executor = new QuestCommand(
creator, now -> List.of(), null, null, claimant,
Clock.fixed(NOW, ZoneOffset.UTC)
);
assertTrue(executor.onCommand(
player, mock(Command.class), "quests", new String[] {"claim"}
));
assertEquals(player, claimantPlayer.get());
}
@Test
void cancelCommandRoutesPlayerAndCurrentTimeToEquivalentGateway() {
RecordingCreator creator = new RecordingCreator();
RecordingCanceller canceller = new RecordingCanceller();
QuestCommand executor = new QuestCommand(
creator, now -> List.of(), null, canceller, Clock.fixed(NOW, ZoneOffset.UTC)
);
Player player = mock(Player.class);
String id = UUID.randomUUID().toString();
assertTrue(executor.onCommand(
player, mock(Command.class), "quests", new String[] {"cancel", id}
));
assertEquals(player, canceller.player);
assertEquals(id, canceller.questId);
assertEquals(NOW, canceller.cancelledAt);
}
@Test
void questIdentifierAutocompleteOnlyUsesActiveAndOwnedQuests() {
RecordingCreator creator = new RecordingCreator();
@@ -139,6 +222,10 @@ final class QuestCommandTest {
executor.onTabComplete(player, command, "quests", new String[] {"cr"}));
assertEquals(List.of("list"),
executor.onTabComplete(player, command, "quests", new String[] {"li"}));
assertEquals(List.of("cancel"),
executor.onTabComplete(player, command, "quests", new String[] {"ca"}));
assertEquals(List.of("claim"),
executor.onTabComplete(player, command, "quests", new String[] {"cl"}));
assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete(
player, command, "quests", new String[] {"create", "sto"}
));
@@ -159,6 +246,20 @@ final class QuestCommandTest {
return new QuestCommand(creator, browser, Clock.fixed(NOW, ZoneOffset.UTC));
}
private static final class RecordingCanceller implements QuestCancellationGateway {
private Player player;
private String questId;
private Instant cancelledAt;
@Override
public QuestClaim cancel(Player cancellingPlayer, String id, Instant instant) {
player = cancellingPlayer;
questId = id;
cancelledAt = instant;
return null;
}
}
private static final class RecordingCompleter implements QuestCompletionGateway {
private Player player;
private String questId;
@@ -0,0 +1,127 @@
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.time.Instant;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class QuestExpiryAndClaimServiceTest {
@Test
void expiryReturnsExactRewardAsTypedClaimAndAcknowledgementRemovesIt() throws Exception {
MemoryQuestRepository repository = new MemoryQuestRepository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
EscrowItem reward = new EscrowItem("DIAMOND", 3, "exact-data");
Quest quest = service.create(
issuer, "Issuer", "STONE", 64, List.of(reward), Instant.EPOCH
);
service.expire(quest.expiresAt());
List<QuestClaim> claims = service.claimsFor(issuer);
assertEquals(QuestStatus.EXPIRED, service.state().quests().get(quest.id()).status());
assertEquals(List.of(reward), claims.getFirst().items());
assertEquals(QuestClaimSource.EXPIRATION, claims.getFirst().source());
assertEquals(QuestClaimType.RETURNED_REWARD, claims.getFirst().type());
service.acknowledgeClaim(issuer, claims.getFirst().id());
assertTrue(service.claimsFor(issuer).isEmpty());
}
@Test
void sevenDayBoundaryIsExclusiveBeforeAndDueExactlyAtExpiration() throws Exception {
QuestService service = new QuestService(new MemoryQuestRepository());
Quest quest = create(service, UUID.randomUUID());
assertTrue(service.expire(quest.expiresAt().minusNanos(1)).isEmpty());
assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status());
assertEquals(1, service.expire(quest.expiresAt()).size());
assertEquals(QuestStatus.EXPIRED, service.state().quests().get(quest.id()).status());
assertTrue(service.expire(quest.expiresAt().plusSeconds(1)).isEmpty());
assertEquals(1, service.claimsFor(quest.issuerId()).size());
}
@Test
void completionCancellationAndExpirationClaimsKeepTheirDistinctSources() throws Exception {
QuestService service = new QuestService(new MemoryQuestRepository());
UUID issuer = UUID.randomUUID();
Quest completed = create(service, issuer);
Quest cancelled = service.create(
issuer, "Issuer", "DIRT", 1,
List.of(new EscrowItem("EMERALD", 2, null)), Instant.EPOCH
);
Quest expired = service.create(
issuer, "Issuer", "SAND", 1,
List.of(new EscrowItem("GOLD_INGOT", 4, null)), Instant.EPOCH
);
service.complete(
completed.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1)
);
service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1));
service.expire(expired.expiresAt());
assertEquals(
List.of(
QuestClaimSource.COMPLETION,
QuestClaimSource.CANCELLATION,
QuestClaimSource.EXPIRATION
),
service.claimsFor(issuer).stream().map(QuestClaim::source).toList()
);
assertEquals(
List.of(
QuestClaimType.DELIVERED_BLOCKS,
QuestClaimType.RETURNED_REWARD,
QuestClaimType.RETURNED_REWARD
),
service.claimsFor(issuer).stream().map(QuestClaim::type).toList()
);
}
@Test
void failedExpirationPersistsNothingAndCanBeRetriedWithoutDuplication() throws Exception {
MemoryQuestRepository repository = new MemoryQuestRepository();
QuestService service = new QuestService(repository);
Quest quest = create(service, UUID.randomUUID());
repository.fail = true;
assertThrows(IOException.class, () -> service.expire(quest.expiresAt()));
assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status());
assertTrue(service.claimsFor(quest.issuerId()).isEmpty());
assertTrue(service.pendingNotifications(quest.issuerId()).isEmpty());
repository.fail = false;
service.expire(quest.expiresAt());
service.expire(quest.expiresAt());
assertEquals(1, service.claimsFor(quest.issuerId()).size());
assertEquals(1, service.pendingNotifications(quest.issuerId()).size());
}
private static Quest create(QuestService service, UUID issuer) throws IOException {
return service.create(
issuer, "Issuer", "STONE", 1,
List.of(new EscrowItem("DIAMOND", 3, null)), Instant.EPOCH
);
}
private static final class MemoryQuestRepository implements QuestRepository {
private QuestState state = QuestState.empty();
private boolean fail;
@Override public QuestState load() { return state; }
@Override
public void save(QuestState candidate) throws IOException {
if (fail) {
throw new IOException("disk full");
}
state = candidate;
}
}
}
@@ -0,0 +1,44 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
final class QuestExpiryTaskTest {
@Test
void scheduledPassExpiresDueQuestAndRequestsImmediateOnlineNotification() throws Exception {
Instant boundary = Instant.parse("2026-09-12T03:00:00Z");
Repository repository = new Repository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
service.create(
issuer, "Issuer", "STONE", 1,
List.of(new EscrowItem("DIAMOND", 1, null)),
boundary.minusSeconds(604800)
);
AtomicReference<UUID> notified = new AtomicReference<>();
QuestExpiryTask task = new QuestExpiryTask(
service, notified::set, Clock.fixed(boundary, ZoneOffset.UTC),
Logger.getAnonymousLogger()
);
task.run();
assertEquals(issuer, notified.get());
assertEquals(1, service.pendingNotifications(issuer).size());
assertEquals(QuestStatus.EXPIRED, service.state().quests().values().iterator().next().status());
}
private static final class Repository implements QuestRepository {
private QuestState state = QuestState.empty();
@Override public QuestState load() { return state; }
@Override public void save(QuestState candidate) { state = candidate; }
}
}
@@ -3,6 +3,7 @@ package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.UUID;
@@ -21,6 +22,45 @@ final class YamlBoardRepositoryTest {
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
void roundTripsWorldIdentityAndCoordinates() throws Exception {
Path path = temporaryDirectory.resolve("boards.yml");
@@ -124,9 +124,43 @@ final class YamlQuestRepositoryTest {
assertEquals(expected, repository.load());
String yaml = Files.readString(path);
assertTrue(yaml.contains("claims:"));
assertTrue(yaml.contains("source: COMPLETION"));
assertTrue(yaml.contains("notifications:"));
}
@Test
void sourceLessHistoricalClaimsInferTypeFromQuestLifecycle() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml");
Files.writeString(path, """
quests:
- id: 00000000-0000-0000-0000-000000000010
issuer-id: 00000000-0000-0000-0000-000000000001
issuer-name: Issuer
requested-material: STONE
requested-amount: 1
created-at: '2026-09-05T03:00:00Z'
expires-at: '2026-09-12T03:00:00Z'
status: CANCELLED
reward:
- material: DIAMOND
amount: 1
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: DIAMOND
amount: 1
""");
QuestClaim claim = new YamlQuestRepository(path).load().claims().values()
.iterator().next().getFirst();
assertEquals(QuestClaimSource.CANCELLATION, claim.source());
assertEquals(QuestClaimType.RETURNED_REWARD, claim.type());
}
@Test
void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml");