2 Commits
Author SHA1 Message Date
dmg e12e31c0ad feat(quests): allow nearby command creation
Release / release (push) Successful in 2m50s
CI / build (push) Successful in 1m8s
2026-09-05 10:03:30 -04:00
dmg 07061a3622 fix(board): make readable signs glow
Release / release (push) Successful in 2m26s
CI / build (push) Successful in 1m4s
2026-09-05 09:36:21 -04:00
19 changed files with 542 additions and 27 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ The approved behavior is specified in the [OKF knowledge bundle](knowledge/index
## Status ## Status
Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. 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. Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. They can instead run `/questadmin createboard physical` to generate a five-wide oak board above the targeted ground anchor, with glowing centered title and browsing-instruction signs above three decorative signs; its visible panel blocks and all five signs are registered. Right-clicking any registered board opens a compact native dashboard with dedicated browsing, creation, and claim screens. A player can request a block and quantity while escrowing the exact reward stack held in their main hand. Every board and `/quests list` show the same active quests with requested blocks, rewards, issuers, and time remaining. Players can complete quests at any board or with `/quests complete <quest>` by delivering the required blocks. Exact escrowed rewards are granted immediately, and delivered blocks are held for the issuer. Issuers can cancel their own active quests at any board or with `/quests cancel <quest>`. Completed deliveries and rewards from cancelled or seven-day-expired quests are held durably and can be collected at any board or with `/quests claim`; inventory overflow drops at the claimant's feet.
## Requirements ## Requirements
@@ -23,7 +23,7 @@ The plugin JAR is written to `build/libs/`.
## Player commands ## Player commands
Player commands are disabled by default and can be enabled persistently by an administrator. When disabled, players are directed to use a physical quest board. Player commands are disabled by default and can be enabled persistently by an administrator. When disabled, players within five blocks of a registered board can still use `/quests create <block> <quantity>` with material and quantity autocomplete; other player command forms remain disabled.
```text ```text
/quests /quests
+14
View File
@@ -98,3 +98,17 @@ description: Chronological record of material decisions affecting Spigot Quest B
- Registered all five signs and the plank face as shared-board interaction locations. - 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. - 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`. - Verified 111 tests and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-05 — Glowing readable board signs
- Enabled glowing front-side text on generated title and instruction signs while retaining non-glowing decorative signs.
- Added exact full-structure detection to refresh existing generated boards once without modifying custom or altered signs.
- Added two-sign snapshot rollback when a refresh cannot complete safely.
- Verified 116 tests and the plugin JAR with `./gradlew clean check jar`.
## 2026-09-05 — Nearby command-based quest creation
- Allowed `/quests create <block> <quantity>` and its material and quantity autocomplete within five blocks of any registered board location even while global player commands are disabled.
- Kept listing, completion, cancellation, and claiming commands disabled under that setting while preserving global command behavior when enabled.
- Enforced same-world Euclidean proximity with an inclusive five-block boundary for custom and generated boards.
- Verified 121 tests and the plugin JAR with `./gradlew clean check jar`.
+1
View File
@@ -17,3 +17,4 @@ description: Catalog of user stories for the Spigot Quest Board plugin.
9. [US-009: Use a screen-fitting quest-board interface](us-009-use-a-screen-fitting-quest-board-interface.md) 9. [US-009: Use a screen-fitting quest-board interface](us-009-use-a-screen-fitting-quest-board-interface.md)
10. [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.md) 10. [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.md)
11. [US-011: Add readable physical-board signage](us-011-add-readable-physical-board-signage.md) 11. [US-011: Add readable physical-board signage](us-011-add-readable-physical-board-signage.md)
12. [US-012: Create quests by command near a board](us-012-create-quests-near-a-board.md)
@@ -14,7 +14,7 @@ As an **administrator**, I want to enable or disable player quest commands so th
- [x] Player `/quests` commands are disabled by default. - [x] Player `/quests` commands are disabled by default.
- [x] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete. - [x] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete.
- [x] The command setting persists across server restarts. - [x] The command setting persists across server restarts.
- [x] When disabled, `/quests`, `list`, `create`, `complete`, `cancel`, and `claim` reject execution with a clear message directing the player to a quest board. - [x] When disabled, `/quests`, `list`, `complete`, `cancel`, and `claim` reject execution with a clear message directing the player to a quest board; nearby creation follows [US-012](us-012-create-quests-near-a-board.md).
- [x] Disabling commands does not prevent any equivalent action through a registered board. - [x] Disabling commands does not prevent any equivalent action through a registered board.
- [x] `/questadmin` remains available to authorized administrators regardless of the player-command setting. - [x] `/questadmin` remains available to authorized administrators regardless of the player-command setting.
- [x] Unauthorized users cannot change the setting. - [x] Unauthorized users cannot change the setting.
@@ -25,3 +25,4 @@ As an **administrator**, I want to enable or disable player quest commands so th
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) - [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md) - [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md)
- [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md) - [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md)
- [US-012: Create quests by command near a board](us-012-create-quests-near-a-board.md)
@@ -20,6 +20,8 @@ As a **player**, I want readable signs on a generated quest board so that I know
- [x] Existing custom single-block boards and unrelated structures are not modified. - [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] Existing-board upgrades never overwrite occupied sign locations and persist new interaction locations failure-safely.
- [x] Automated tests verify sign placement, readable text, decorative text, interaction registration, obstruction handling, and prior-layout migration. - [x] Automated tests verify sign placement, readable text, decorative text, interaction registration, obstruction handling, and prior-layout migration.
- [x] The title and instruction signs use glowing front-side text for readability while decorative signs remain non-glowing.
- [x] New boards receive glowing readable signs, and exact existing generated five-sign boards are refreshed on startup without modifying custom signs.
## Related ## Related
@@ -0,0 +1,29 @@
---
type: User Story
title: "US-012: Create quests by command near a board"
description: Allow command-based quest creation and material autocomplete near a physical board even when global player commands are disabled.
status: done
---
# US-012: Create quests by command near a board
As a **player**, I want to use the quest-creation command near a quest board so that I can use material autocomplete while still interacting at the physical board.
## Acceptance criteria
- [x] When global player quest commands are disabled, `/quests create <block> <quantity>` remains available to a player within five blocks of a registered board interaction location.
- [x] Material and quantity autocomplete remain available for nearby quest creation.
- [x] A player farther than five blocks from every registered board is directed to move closer and no quest or escrow change occurs.
- [x] Board proximity requires the player and registered location to be in the same world.
- [x] A distance of exactly five blocks is accepted and a greater distance is rejected.
- [x] Root listing, `list`, `complete`, `cancel`, and `claim` remain disabled while global player commands are disabled.
- [x] When global player quest commands are enabled, all command behavior, including creation, remains available regardless of board proximity.
- [x] Custom single-block boards and every registered interaction location on generated boards satisfy the proximity requirement.
- [x] Board-dialog creation remains available regardless of command settings or command proximity rules.
- [x] Automated tests verify boundaries, cross-world behavior, autocomplete, command settings, disabled subcommands, and generated-board proximity.
## Related
- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md)
- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md)
- [US-008: Control player quest commands](us-008-control-player-quest-commands.md)
@@ -0,0 +1,8 @@
package games.dmg.spigotquestboard;
import java.util.UUID;
@FunctionalInterface
interface BoardProximity {
boolean isWithin(UUID worldId, double x, double y, double z, double maximumDistance);
}
@@ -7,7 +7,7 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
final class BoardRegistry { final class BoardRegistry implements BoardProximity {
private final BoardRepository repository; private final BoardRepository repository;
private Map<BoardId, RegisteredBoard> boards; private Map<BoardId, RegisteredBoard> boards;
@@ -46,6 +46,30 @@ final class BoardRegistry {
return boards.size(); return boards.size();
} }
@Override
public synchronized boolean isWithin(
java.util.UUID worldId,
double x,
double y,
double z,
double maximumDistance
) {
Objects.requireNonNull(worldId, "worldId");
if (maximumDistance < 0.0) {
return false;
}
double maximumDistanceSquared = maximumDistance * maximumDistance;
return boards.keySet().stream()
.filter(id -> id.worldId().equals(worldId))
.anyMatch(id -> {
double deltaX = x - id.x();
double deltaY = y - id.y();
double deltaZ = z - id.z();
return deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ
<= maximumDistanceSquared;
});
}
synchronized Map<BoardId, RegisteredBoard> registeredBoards() { synchronized Map<BoardId, RegisteredBoard> registeredBoards() {
return Map.copyOf(boards); return Map.copyOf(boards);
} }
@@ -13,6 +13,7 @@ import org.bukkit.block.Sign;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.WallSign; import org.bukkit.block.data.type.WallSign;
import org.bukkit.block.sign.Side; import org.bukkit.block.sign.Side;
import org.bukkit.block.sign.SignSide;
final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld { final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld {
private static final Component DECORATIVE_TEXT = Component.text("xxxxxxxx") private static final Component DECORATIVE_TEXT = Component.text("xxxxxxxx")
@@ -64,9 +65,11 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld {
throw new IllegalStateException("Oak wall sign did not create sign state"); throw new IllegalStateException("Oak wall sign did not create sign state");
} }
List<Component> lines = signLines(placement.signKind()); List<Component> lines = signLines(placement.signKind());
SignSide front = sign.getSide(Side.FRONT);
for (int line = 0; line < lines.size(); line++) { for (int line = 0; line < lines.size(); line++) {
sign.getSide(Side.FRONT).line(line, lines.get(line)); front.line(line, lines.get(line));
} }
front.setGlowingText(placement.signKind() != PhysicalBoardSignKind.DECORATIVE);
if (!sign.update(true, false)) { if (!sign.update(true, false)) {
throw new IllegalStateException("Could not configure physical quest-board sign"); throw new IllegalStateException("Could not configure physical quest-board sign");
} }
@@ -74,6 +77,14 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld {
@Override @Override
public boolean matches(PhysicalBoardPlan.Placement placement, BoardFacing facing) { public boolean matches(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
return matches(placement, facing,
placement.signKind() != PhysicalBoardSignKind.DECORATIVE);
}
@Override
public boolean matches(
PhysicalBoardPlan.Placement placement, BoardFacing facing, boolean glowingText
) {
Block block = block(placement.location()); Block block = block(placement.location());
Material material = Material.matchMaterial(placement.material()); Material material = Material.matchMaterial(placement.material());
if (material == null || block.getType() != material) { if (material == null || block.getType() != material) {
@@ -87,9 +98,13 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld {
|| !(block.getState() instanceof Sign sign)) { || !(block.getState() instanceof Sign sign)) {
return false; return false;
} }
SignSide front = sign.getSide(Side.FRONT);
if (front.isGlowingText() != glowingText) {
return false;
}
List<Component> expected = signLines(placement.signKind()); List<Component> expected = signLines(placement.signKind());
for (int line = 0; line < expected.size(); line++) { for (int line = 0; line < expected.size(); line++) {
if (!expected.get(line).equals(sign.getSide(Side.FRONT).line(line))) { if (!expected.get(line).equals(front.line(line))) {
return false; return false;
} }
} }
@@ -32,14 +32,21 @@ final class PhysicalBoardUpgrader {
int upgraded = 0; int upgraded = 0;
Map<BoardId, RegisteredBoard> registered = registry.registeredBoards(); Map<BoardId, RegisteredBoard> registered = registry.registeredBoards();
for (Candidate candidate : candidates(registered)) { for (Candidate candidate : candidates(registered)) {
if (!hasLegacyRegistrations(candidate, registered)) { boolean legacy = hasLegacyRegistrations(candidate, registered);
boolean full = hasFullRegistrations(candidate, registered);
if (!legacy && !full) {
continue; continue;
} }
PhysicalBoardWorld world = worlds.open(candidate.anchor().worldId()); PhysicalBoardWorld world = worlds.open(candidate.anchor().worldId());
if (world != null && isLegacyBoard(candidate, world) if (world == null) {
&& upgrade(candidate, world)) { continue;
}
if (legacy && isLegacyBoard(candidate, world) && upgradeLegacy(candidate, world)) {
upgraded++; upgraded++;
registered = registry.registeredBoards(); registered = registry.registeredBoards();
} else if (full && isNonGlowingFullBoard(candidate, world)) {
refreshReadableSigns(candidate, world);
upgraded++;
} }
} }
return upgraded; return upgraded;
@@ -60,6 +67,19 @@ final class PhysicalBoardUpgrader {
); );
} }
private static boolean hasFullRegistrations(
Candidate candidate, Map<BoardId, RegisteredBoard> registered
) {
PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing());
for (BoardId location : plan.interactionLocations()) {
RegisteredBoard board = registered.get(location);
if (board == null || !candidate.worldName().equals(board.worldName())) {
return false;
}
}
return true;
}
private boolean isLegacyBoard(Candidate candidate, PhysicalBoardWorld world) { private boolean isLegacyBoard(Candidate candidate, PhysicalBoardWorld world) {
PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing()); PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing());
for (PhysicalBoardPlan.Placement placement : legacyPlacements(plan)) { for (PhysicalBoardPlan.Placement placement : legacyPlacements(plan)) {
@@ -75,8 +95,21 @@ final class PhysicalBoardUpgrader {
return true; return true;
} }
private boolean upgrade(Candidate candidate, PhysicalBoardWorld world) throws IOException { private boolean isNonGlowingFullBoard(Candidate candidate, PhysicalBoardWorld world) {
List<PhysicalBoardPlan.Placement> signs = newSigns( PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing());
for (PhysicalBoardPlan.Placement placement : plan.placements()) {
boolean matches = placement.sign()
? world.matches(placement, candidate.facing(), false)
: world.matches(placement, candidate.facing());
if (!matches) {
return false;
}
}
return true;
}
private boolean upgradeLegacy(Candidate candidate, PhysicalBoardWorld world) throws IOException {
List<PhysicalBoardPlan.Placement> signs = readableSigns(
PhysicalBoardPlan.create(candidate.anchor(), candidate.facing()) PhysicalBoardPlan.create(candidate.anchor(), candidate.facing())
); );
Map<BoardId, Object> snapshots = new LinkedHashMap<>(); Map<BoardId, Object> snapshots = new LinkedHashMap<>();
@@ -153,7 +186,30 @@ final class PhysicalBoardUpgrader {
return Set.copyOf(locations); return Set.copyOf(locations);
} }
private void refreshReadableSigns(Candidate candidate, PhysicalBoardWorld world)
throws IOException {
List<PhysicalBoardPlan.Placement> signs = readableSigns(
PhysicalBoardPlan.create(candidate.anchor(), candidate.facing())
);
Map<BoardId, Object> snapshots = new LinkedHashMap<>();
try {
for (PhysicalBoardPlan.Placement sign : signs) {
snapshots.put(sign.location(), world.snapshot(sign.location()));
}
for (PhysicalBoardPlan.Placement sign : signs) {
world.place(sign, candidate.facing());
}
} catch (RuntimeException exception) {
rollback(world, snapshots, exception);
throw new IOException("Could not refresh physical quest-board signs", exception);
}
}
private static List<PhysicalBoardPlan.Placement> newSigns(PhysicalBoardPlan plan) { private static List<PhysicalBoardPlan.Placement> newSigns(PhysicalBoardPlan plan) {
return readableSigns(plan);
}
private static List<PhysicalBoardPlan.Placement> readableSigns(PhysicalBoardPlan plan) {
return plan.placements().stream().filter(placement -> return plan.placements().stream().filter(placement ->
placement.signKind() == PhysicalBoardSignKind.TITLE placement.signKind() == PhysicalBoardSignKind.TITLE
|| placement.signKind() == PhysicalBoardSignKind.INSTRUCTION || placement.signKind() == PhysicalBoardSignKind.INSTRUCTION
@@ -11,5 +11,11 @@ interface PhysicalBoardWorld {
return false; return false;
} }
default boolean matches(
PhysicalBoardPlan.Placement placement, BoardFacing facing, boolean glowingText
) {
return false;
}
void restore(BoardId location, Object snapshot); void restore(BoardId location, Object snapshot);
} }
@@ -14,6 +14,7 @@ import org.bukkit.entity.Player;
final class QuestCommand implements CommandExecutor, TabCompleter { final class QuestCommand implements CommandExecutor, TabCompleter {
private static final List<String> QUANTITIES = List.of("1", "16", "32", "64"); private static final List<String> QUANTITIES = List.of("1", "16", "32", "64");
private static final double COMMAND_CREATION_DISTANCE = 5.0;
private final QuestCreationGateway creator; private final QuestCreationGateway creator;
private final QuestBrowser browser; private final QuestBrowser browser;
private final QuestCompletionGateway completer; private final QuestCompletionGateway completer;
@@ -21,6 +22,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
private final QuestClaimGateway claimant; private final QuestClaimGateway claimant;
private final Clock clock; private final Clock clock;
private final PlayerCommandSettings playerCommands; private final PlayerCommandSettings playerCommands;
private final BoardProximity boardProximity;
QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this(creator, browser, null, null, null, clock); this(creator, browser, null, null, null, clock);
@@ -53,7 +55,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestClaimGateway claimant, QuestClaimGateway claimant,
Clock clock Clock clock
) { ) {
this(creator, browser, completer, canceller, claimant, clock, null); this(creator, browser, completer, canceller, claimant, clock, null, null);
} }
QuestCommand( QuestCommand(
@@ -64,6 +66,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestClaimGateway claimant, QuestClaimGateway claimant,
Clock clock, Clock clock,
PlayerCommandSettings playerCommands PlayerCommandSettings playerCommands
) {
this(creator, browser, completer, canceller, claimant, clock, playerCommands, null);
}
QuestCommand(
QuestCreationGateway creator,
QuestBrowser browser,
QuestCompletionGateway completer,
QuestCancellationGateway canceller,
QuestClaimGateway claimant,
Clock clock,
PlayerCommandSettings playerCommands,
BoardProximity boardProximity
) { ) {
this.creator = Objects.requireNonNull(creator, "creator"); this.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser"); this.browser = Objects.requireNonNull(browser, "browser");
@@ -72,6 +87,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
this.claimant = claimant; this.claimant = claimant;
this.clock = Objects.requireNonNull(clock, "clock"); this.clock = Objects.requireNonNull(clock, "clock");
this.playerCommands = playerCommands; this.playerCommands = playerCommands;
this.boardProximity = boardProximity;
} }
@Override @Override
@@ -79,10 +95,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
CommandSender sender, Command command, String label, String[] arguments CommandSender sender, Command command, String label, String[] arguments
) { ) {
if (!playerCommandsEnabled()) { if (!playerCommandsEnabled()) {
sender.sendMessage( if (isCreate(arguments) && sender instanceof Player player) {
"Player quest commands are disabled. Use a physical quest board instead." if (!isNearBoard(player)) {
); sender.sendMessage(
return true; "Move closer to a quest board to create a quest by command."
);
return true;
}
} else {
sender.sendMessage(
"Player quest commands are disabled. Use a physical quest board instead."
);
return true;
}
} }
if (arguments.length == 0 if (arguments.length == 0
|| (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) { || (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) {
@@ -178,9 +203,20 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
public List<String> onTabComplete( public List<String> onTabComplete(
CommandSender sender, Command command, String alias, String[] arguments CommandSender sender, Command command, String alias, String[] arguments
) { ) {
if (!playerCommandsEnabled() || !(sender instanceof Player)) { if (!(sender instanceof Player player)) {
return List.of(); return List.of();
} }
if (!playerCommandsEnabled()) {
if (!isNearBoard(player)) {
return List.of();
}
if (arguments.length == 1) {
return startsWith(List.of("create"), arguments[0]);
}
if (!isCreate(arguments)) {
return List.of();
}
}
if (arguments.length == 1) { if (arguments.length == 1) {
return startsWith( return startsWith(
List.of("create", "list", "complete", "cancel", "claim"), arguments[0] List.of("create", "list", "complete", "cancel", "claim"), arguments[0]
@@ -196,7 +232,6 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
return startsWith(browser.completableQuestIds(clock.instant()), arguments[1]); return startsWith(browser.completableQuestIds(clock.instant()), arguments[1]);
} }
if (arguments.length == 2 && "cancel".equalsIgnoreCase(arguments[0])) { if (arguments.length == 2 && "cancel".equalsIgnoreCase(arguments[0])) {
Player player = (Player) sender;
return startsWith( return startsWith(
browser.cancellableQuestIds(player.getUniqueId(), clock.instant()), arguments[1] browser.cancellableQuestIds(player.getUniqueId(), clock.instant()), arguments[1]
); );
@@ -208,6 +243,21 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
return playerCommands == null || playerCommands.enabled(); return playerCommands == null || playerCommands.enabled();
} }
private boolean isNearBoard(Player player) {
if (boardProximity == null) {
return false;
}
org.bukkit.Location location = player.getLocation();
return boardProximity.isWithin(
player.getWorld().getUID(), location.getX(), location.getY(), location.getZ(),
COMMAND_CREATION_DISTANCE
);
}
private static boolean isCreate(String[] arguments) {
return arguments.length > 0 && "create".equalsIgnoreCase(arguments[0]);
}
private static List<String> startsWith(List<String> candidates, String prefix) { private static List<String> startsWith(List<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT); String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.stream() return candidates.stream()
@@ -98,7 +98,8 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
return new CommandHandlers( return new CommandHandlers(
new QuestAdminCommand(boards, playerCommands), new QuestAdminCommand(boards, playerCommands),
new QuestCommand( new QuestCommand(
creator, browser, completer, canceller, claimant, clock, playerCommands creator, browser, completer, canceller, claimant, clock, playerCommands,
boards
) )
); );
} }
@@ -69,6 +69,24 @@ final class BoardRegistryTest {
assertEquals(0, repository.saveCount); assertEquals(0, repository.saveCount);
} }
@Test
void proximityUsesSameWorldAndAcceptsExactFiveBlockBoundary() throws Exception {
RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD)));
BoardRegistry registry = new BoardRegistry(repository);
assertTrue(registry.isWithin(
BOARD.id().worldId(), BOARD.id().x() + 3.0, BOARD.id().y() + 4.0,
BOARD.id().z(), 5.0
));
assertFalse(registry.isWithin(
BOARD.id().worldId(), BOARD.id().x() + 3.01, BOARD.id().y() + 4.0,
BOARD.id().z(), 5.0
));
assertFalse(registry.isWithin(
UUID.randomUUID(), BOARD.id().x(), BOARD.id().y(), BOARD.id().z(), 5.0
));
}
@Test @Test
void failedPersistenceDoesNotPublishBoard() throws Exception { void failedPersistenceDoesNotPublishBoard() throws Exception {
BoardRepository repository = new BoardRepository() { BoardRepository repository = new BoardRepository() {
@@ -89,6 +89,34 @@ final class BukkitPhysicalBoardWorldTest {
assertFalse(adapter.matches(placement, BoardFacing.SOUTH)); assertFalse(adapter.matches(placement, BoardFacing.SOUTH));
} }
@Test
void readableSignsGlowWhileDecorativeSignsDoNot() {
UUID worldId = UUID.randomUUID();
BoardId location = new BoardId(worldId, 1, 65, 2);
World world = mock(World.class);
Block block = mock(Block.class);
WallSign wallSign = mock(WallSign.class);
Sign sign = mock(Sign.class);
SignSide signSide = mock(SignSide.class);
when(world.getUID()).thenReturn(worldId);
when(world.getBlockAt(1, 65, 2)).thenReturn(block);
when(block.getBlockData()).thenReturn(wallSign);
when(block.getState()).thenReturn(sign);
when(sign.getSide(Side.FRONT)).thenReturn(signSide);
when(sign.update(true, false)).thenReturn(true);
BukkitPhysicalBoardWorld adapter = new BukkitPhysicalBoardWorld(world);
adapter.place(new PhysicalBoardPlan.Placement(
location, "OAK_WALL_SIGN", PhysicalBoardSignKind.TITLE
), BoardFacing.NORTH);
verify(signSide).setGlowingText(true);
adapter.place(new PhysicalBoardPlan.Placement(
location, "OAK_WALL_SIGN", PhysicalBoardSignKind.DECORATIVE
), BoardFacing.NORTH);
verify(signSide).setGlowingText(false);
}
@Test @Test
void rendersReadableTitleAndInstructionText() { void rendersReadableTitleAndInstructionText() {
assertEquals( assertEquals(
@@ -41,6 +41,10 @@ final class PhysicalBoardCreatorTest {
assertTrue(repository.state.boards().stream().allMatch( assertTrue(repository.state.boards().stream().allMatch(
board -> board.worldName().equals("survival") board -> board.worldName().equals("survival")
)); ));
assertTrue(PhysicalBoardPlan.create(ANCHOR, BoardFacing.NORTH)
.interactionLocations().stream().allMatch(location -> registry.isWithin(
location.worldId(), location.x(), location.y(), location.z(), 5.0
)));
} }
@Test @Test
@@ -35,6 +35,33 @@ final class PhysicalBoardUpgraderTest {
assertEquals(2, world.placed.size()); assertEquals(2, world.placed.size());
} }
@Test
void refreshesBothReadableSignsOnAnExactRegisteredFullBoard() throws Exception {
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
BoardRegistry registry = new BoardRegistry(repository);
FakeWorld world = fullWorld(false);
assertEquals(1, upgrader(registry, world).upgrade());
assertEquals(17, registry.size());
assertEquals(0, repository.saves);
assertEquals(Set.of(PhysicalBoardSignKind.TITLE, PhysicalBoardSignKind.INSTRUCTION),
world.placed.values().stream().map(PhysicalBoardPlan.Placement::signKind)
.collect(Collectors.toSet()));
assertTrue(newSigns().stream().allMatch(sign -> world.glowing.get(sign.location())));
}
@Test
void doesNotReportOrRefreshAnAlreadyGlowingFullBoard() throws Exception {
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
FakeWorld world = fullWorld(true);
assertEquals(0, upgrader(new BoardRegistry(repository), world).upgrade());
assertTrue(world.placed.isEmpty());
assertEquals(0, repository.saves);
}
@Test @Test
void doesNotAlterCustomBoardsOrStructuresThatDoNotExactlyMatch() throws Exception { void doesNotAlterCustomBoardsOrStructuresThatDoNotExactlyMatch() throws Exception {
RegisteredBoard custom = new RegisteredBoard( RegisteredBoard custom = new RegisteredBoard(
@@ -58,6 +85,23 @@ final class PhysicalBoardUpgraderTest {
assertTrue(changedWorld.placed.isEmpty()); assertTrue(changedWorld.placed.isEmpty());
} }
@Test
void doesNotRefreshAFullBoardWithCustomSignText() throws Exception {
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
FakeWorld world = fullWorld(false);
PhysicalBoardPlan.Placement title = newSigns().stream()
.filter(sign -> sign.signKind() == PhysicalBoardSignKind.TITLE)
.findFirst().orElseThrow();
world.existing.put(title.location(), new PhysicalBoardPlan.Placement(
title.location(), title.material(), PhysicalBoardSignKind.INSTRUCTION
));
assertEquals(0, upgrader(new BoardRegistry(repository), world).upgrade());
assertTrue(world.placed.isEmpty());
assertTrue(newSigns().stream().noneMatch(sign -> world.glowing.get(sign.location())));
}
@Test @Test
void doesNotOverwriteAnOccupiedNewSignCell() throws Exception { void doesNotOverwriteAnOccupiedNewSignCell() throws Exception {
RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false); RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false);
@@ -89,6 +133,19 @@ final class PhysicalBoardUpgraderTest {
assertEquals(0, repository.saves); assertEquals(0, repository.saves);
} }
@Test
void rollsBackBothReadableSignsWhenFullBoardRefreshFails() throws Exception {
RecordingRepository repository = new RecordingRepository(fullRegistrations(), false);
FakeWorld world = fullWorld(false);
world.failPlacement = 1;
assertThrows(IOException.class,
() -> upgrader(new BoardRegistry(repository), world).upgrade());
assertTrue(newSigns().stream().noneMatch(sign -> world.glowing.get(sign.location())));
assertEquals(0, repository.saves);
}
@Test @Test
void rollsBackBothCellsWhenAtomicPersistenceFails() throws Exception { void rollsBackBothCellsWhenAtomicPersistenceFails() throws Exception {
RecordingRepository repository = new RecordingRepository(legacyRegistrations(), true); RecordingRepository repository = new RecordingRepository(legacyRegistrations(), true);
@@ -120,6 +177,12 @@ final class PhysicalBoardUpgraderTest {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
private static Set<RegisteredBoard> fullRegistrations() {
return plan().interactionLocations().stream()
.map(location -> new RegisteredBoard(location, "survival"))
.collect(Collectors.toSet());
}
private static FakeWorld legacyWorld() { private static FakeWorld legacyWorld() {
FakeWorld world = new FakeWorld(); FakeWorld world = new FakeWorld();
plan().placements().stream().filter(placement -> plan().placements().stream().filter(placement ->
@@ -132,6 +195,19 @@ final class PhysicalBoardUpgraderTest {
return world; return world;
} }
private static FakeWorld fullWorld(boolean readableGlowing) {
FakeWorld world = new FakeWorld();
plan().placements().forEach(placement -> {
world.existing.put(placement.location(), placement);
if (placement.sign()) {
world.facings.put(placement.location(), FACING);
world.glowing.put(placement.location(),
placement.signKind() != PhysicalBoardSignKind.DECORATIVE && readableGlowing);
}
});
return world;
}
private static java.util.List<PhysicalBoardPlan.Placement> newSigns() { private static java.util.List<PhysicalBoardPlan.Placement> newSigns() {
return plan().placements().stream().filter(placement -> return plan().placements().stream().filter(placement ->
placement.signKind() == PhysicalBoardSignKind.TITLE placement.signKind() == PhysicalBoardSignKind.TITLE
@@ -140,8 +216,13 @@ final class PhysicalBoardUpgraderTest {
} }
private static final class FakeWorld implements PhysicalBoardWorld { private static final class FakeWorld implements PhysicalBoardWorld {
private record Snapshot(
PhysicalBoardPlan.Placement placement, BoardFacing facing, Boolean glowing
) { }
private final Map<BoardId, PhysicalBoardPlan.Placement> existing = new HashMap<>(); private final Map<BoardId, PhysicalBoardPlan.Placement> existing = new HashMap<>();
private final Map<BoardId, BoardFacing> facings = new HashMap<>(); private final Map<BoardId, BoardFacing> facings = new HashMap<>();
private final Map<BoardId, Boolean> glowing = new HashMap<>();
private final Map<BoardId, PhysicalBoardPlan.Placement> placed = new HashMap<>(); private final Map<BoardId, PhysicalBoardPlan.Placement> placed = new HashMap<>();
private int placements; private int placements;
private int failPlacement = -1; private int failPlacement = -1;
@@ -151,7 +232,9 @@ final class PhysicalBoardUpgraderTest {
} }
@Override public Object snapshot(BoardId location) { @Override public Object snapshot(BoardId location) {
return existing.get(location); return new Snapshot(
existing.get(location), facings.get(location), glowing.get(location)
);
} }
@Override public void place(PhysicalBoardPlan.Placement placement, BoardFacing facing) { @Override public void place(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
@@ -160,6 +243,8 @@ final class PhysicalBoardUpgraderTest {
} }
existing.put(placement.location(), placement); existing.put(placement.location(), placement);
facings.put(placement.location(), facing); facings.put(placement.location(), facing);
glowing.put(placement.location(),
placement.signKind() != PhysicalBoardSignKind.DECORATIVE);
placed.put(placement.location(), placement); placed.put(placement.location(), placement);
} }
@@ -170,13 +255,25 @@ final class PhysicalBoardUpgraderTest {
&& (!placement.sign() || facing == facings.get(placement.location())); && (!placement.sign() || facing == facings.get(placement.location()));
} }
@Override public boolean matches(
PhysicalBoardPlan.Placement placement, BoardFacing facing, boolean glowingText
) {
return matches(placement, facing)
&& glowingText == glowing.getOrDefault(placement.location(), false);
}
@Override public void restore(BoardId location, Object snapshot) { @Override public void restore(BoardId location, Object snapshot) {
if (snapshot == null) { Snapshot captured = (Snapshot) snapshot;
existing.remove(location); restore(existing, location, captured.placement());
facings.remove(location); restore(facings, location, captured.facing());
restore(glowing, location, captured.glowing());
}
private static <T> void restore(Map<BoardId, T> values, BoardId location, T value) {
if (value == null) {
values.remove(location);
} else { } else {
PhysicalBoardPlan.Placement placement = (PhysicalBoardPlan.Placement) snapshot; values.put(location, value);
existing.put(location, placement);
} }
} }
} }
@@ -11,6 +11,8 @@ import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -44,6 +46,54 @@ final class PluginCommandWiringTest {
assertTrue(worldLookups.get() > 0); assertTrue(worldLookups.get() > 0);
} }
@Test
void playerCommandUsesBoardRegistryForNearbyCreation() throws Exception {
UUID worldId = UUID.randomUUID();
BoardId boardId = new BoardId(worldId, 10, 64, 20);
BoardRegistry boards = new BoardRegistry(new BoardRepository() {
@Override public BoardState load() {
return new BoardState(Set.of(new RegisteredBoard(boardId, "survival")));
}
@Override public void save(BoardState state) { }
});
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return false; }
@Override public void saveEnabled(boolean enabled) { }
}
);
QuestCreationGateway creator = mock(QuestCreationGateway.class);
Quest created = mock(Quest.class);
when(created.id()).thenReturn(UUID.randomUUID());
when(creator.create(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.any()
)).thenReturn(created);
SpigotQuestBoardPlugin.CommandHandlers handlers =
SpigotQuestBoardPlugin.commandHandlers(
boards, settings, creator, mock(QuestBrowser.class), null, null, null,
Clock.systemUTC()
);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
Player player = mock(Player.class);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 13, 68, 20));
assertTrue(handlers.quests().onCommand(
player, mock(Command.class), "quests", new String[] {"create", "stone", "1"}
));
verify(creator).create(
org.mockito.ArgumentMatchers.eq(player),
org.mockito.ArgumentMatchers.eq("stone"),
org.mockito.ArgumentMatchers.eq(1),
org.mockito.ArgumentMatchers.any()
);
}
@Test @Test
void adminAndPlayerCommandsShareThePersistedSetting() throws Exception { void adminAndPlayerCommandsShareThePersistedSetting() throws Exception {
PlayerCommandSettings settings = new PlayerCommandSettings( PlayerCommandSettings settings = new PlayerCommandSettings(
@@ -14,15 +14,19 @@ import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class QuestCommandTest { final class QuestCommandTest {
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z"); private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
private static final UUID WORLD_ID =
UUID.fromString("00000000-0000-0000-0000-000000000099");
@Test @Test
void disabledSettingGatesEveryPlayerCommandFormAndAutocomplete() throws Exception { void disabledSettingGatesNonCreationCommandForms() throws Exception {
QuestCreationGateway creator = mock(QuestCreationGateway.class); QuestCreationGateway creator = mock(QuestCreationGateway.class);
QuestBrowser browser = mock(QuestBrowser.class); QuestBrowser browser = mock(QuestBrowser.class);
QuestCompletionGateway completer = mock(QuestCompletionGateway.class); QuestCompletionGateway completer = mock(QuestCompletionGateway.class);
@@ -43,7 +47,6 @@ final class QuestCommandTest {
List<String[]> forms = List.of( List<String[]> forms = List.of(
new String[] {}, new String[] {},
new String[] {"list"}, new String[] {"list"},
new String[] {"create", "stone", "1"},
new String[] {"complete", "quest-id"}, new String[] {"complete", "quest-id"},
new String[] {"cancel", "quest-id"}, new String[] {"cancel", "quest-id"},
new String[] {"claim"} new String[] {"claim"}
@@ -62,6 +65,97 @@ final class QuestCommandTest {
verifyNoInteractions(creator, browser, completer, canceller, claimant); verifyNoInteractions(creator, browser, completer, canceller, claimant);
} }
@Test
void disabledSettingAllowsNearbyCreationAndOnlyCreationAutocomplete() throws Exception {
RecordingCreator creator = new RecordingCreator();
PlayerCommandSettings settings = settings(false);
BoardProximity proximity = (worldId, x, y, z, maximumDistance) -> {
assertEquals(WORLD_ID, worldId);
assertEquals(4.0, x);
assertEquals(3.0, y);
assertEquals(0.0, z);
assertEquals(5.0, maximumDistance);
return true;
};
QuestCommand executor = new QuestCommand(
creator, now -> List.of(), null, null, null,
Clock.fixed(NOW, ZoneOffset.UTC), settings, proximity
);
Player player = playerAt(4.0, 3.0, 0.0);
Command command = mock(Command.class);
assertTrue(executor.onCommand(
player, command, "quests", new String[] {"create", "stone", "1"}
));
assertEquals(1, creator.calls);
assertEquals(List.of("create"), executor.onTabComplete(
player, command, "quests", new String[] {""}
));
assertTrue(executor.onTabComplete(
player, command, "quests", new String[] {"li"}
).isEmpty());
assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete(
player, command, "quests", new String[] {"create", "sto"}
));
assertEquals(List.of("1", "16", "32", "64"), executor.onTabComplete(
player, command, "quests", new String[] {"create", "stone", ""}
));
assertTrue(executor.onTabComplete(
player, command, "quests", new String[] {"complete", ""}
).isEmpty());
}
@Test
void disabledSettingRejectsAwayCreationBeforeCreatorOrEscrowGateway() throws Exception {
QuestCreationGateway creator = mock(QuestCreationGateway.class);
QuestCommand executor = new QuestCommand(
creator, mock(QuestBrowser.class), null, null, null,
Clock.fixed(NOW, ZoneOffset.UTC), settings(false),
(worldId, x, y, z, maximumDistance) -> false
);
Player player = playerAt(5.01, 0.0, 0.0);
Command command = mock(Command.class);
assertTrue(executor.onCommand(
player, command, "quests", new String[] {"create", "stone", "1"}
));
verify(player).sendMessage("Move closer to a quest board to create a quest by command.");
verifyNoInteractions(creator);
assertTrue(executor.onTabComplete(
player, command, "quests", new String[] {""}
).isEmpty());
assertTrue(executor.onTabComplete(
player, command, "quests", new String[] {"create", "sto"}
).isEmpty());
}
@Test
void enabledSettingPreservesAllCommandsWithoutConsultingProximity() throws Exception {
RecordingCreator creator = new RecordingCreator();
QuestBrowser browser = now -> List.of(creator.quest(1));
QuestCommand executor = new QuestCommand(
creator, browser, null, null, null, Clock.fixed(NOW, ZoneOffset.UTC),
settings(true),
(worldId, x, y, z, maximumDistance) -> {
throw new AssertionError("Enabled commands must bypass board proximity");
}
);
Player player = mock(Player.class);
Command command = mock(Command.class);
assertTrue(executor.onCommand(
player, command, "quests", new String[] {"create", "stone", "1"}
));
assertTrue(executor.onCommand(player, command, "quests", new String[] {"list"}));
assertEquals(List.of("create"), executor.onTabComplete(
player, command, "quests", new String[] {"cr"}
));
assertEquals(1, creator.calls);
}
@Test @Test
void routesValidatedCreateArgumentsWithCurrentUtcTime() { void routesValidatedCreateArgumentsWithCurrentUtcTime() {
RecordingCreator creator = new RecordingCreator(); RecordingCreator creator = new RecordingCreator();
@@ -238,6 +332,23 @@ final class QuestCommandTest {
).isEmpty()); ).isEmpty());
} }
private static PlayerCommandSettings settings(boolean enabled) throws Exception {
return new PlayerCommandSettings(new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return enabled; }
@Override public void saveEnabled(boolean newValue) { }
});
}
private static Player playerAt(double x, double y, double z) {
World world = mock(World.class);
when(world.getUID()).thenReturn(WORLD_ID);
Location location = new Location(world, x, y, z);
Player player = mock(Player.class);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(location);
return player;
}
private static QuestCommand command(RecordingCreator creator) { private static QuestCommand command(RecordingCreator creator) {
return command(creator, now -> List.of()); return command(creator, now -> List.of());
} }