feat(board): add shared physical quest boards
Release / release (push) Successful in 4m19s
CI / build (push) Successful in 1m20s

This commit is contained in:
dmg
2026-09-04 23:33:18 -04:00
parent f024744440
commit 63779d1631
20 changed files with 645 additions and 12 deletions
@@ -0,0 +1,17 @@
package games.dmg.spigotquestboard;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.block.Block;
record BoardId(UUID worldId, int x, int y, int z) {
BoardId {
Objects.requireNonNull(worldId, "worldId");
}
static BoardId from(Block block) {
return new BoardId(
block.getWorld().getUID(), block.getX(), block.getY(), block.getZ()
);
}
}
@@ -0,0 +1,6 @@
package games.dmg.spigotquestboard;
enum BoardRegistrationResult {
CREATED,
ALREADY_REGISTERED
}
@@ -0,0 +1,47 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
final class BoardRegistry {
private final BoardRepository repository;
private Map<BoardId, RegisteredBoard> boards;
BoardRegistry(BoardRepository repository) throws IOException {
this.repository = Objects.requireNonNull(repository, "repository");
boards = index(repository.load());
}
synchronized BoardRegistrationResult register(RegisteredBoard board) throws IOException {
Objects.requireNonNull(board, "board");
if (boards.containsKey(board.id())) {
return BoardRegistrationResult.ALREADY_REGISTERED;
}
Map<BoardId, RegisteredBoard> candidate = new LinkedHashMap<>(boards);
candidate.put(board.id(), board);
repository.save(new BoardState(Set.copyOf(candidate.values())));
boards = Map.copyOf(candidate);
return BoardRegistrationResult.CREATED;
}
synchronized boolean contains(BoardId id) {
return boards.containsKey(id);
}
synchronized int size() {
return boards.size();
}
private static Map<BoardId, RegisteredBoard> index(BoardState state) throws IOException {
Map<BoardId, RegisteredBoard> indexed = new LinkedHashMap<>();
for (RegisteredBoard board : state.boards()) {
if (indexed.put(board.id(), board) != null) {
throw new IOException("Duplicate quest board location: " + board.id());
}
}
return Map.copyOf(indexed);
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
interface BoardRepository {
BoardState load() throws IOException;
void save(BoardState state) throws IOException;
}
@@ -0,0 +1,14 @@
package games.dmg.spigotquestboard;
import java.util.Objects;
import java.util.Set;
record BoardState(Set<RegisteredBoard> boards) {
BoardState {
boards = Set.copyOf(Objects.requireNonNull(boards, "boards"));
}
static BoardState empty() {
return new BoardState(Set.of());
}
}
@@ -0,0 +1,50 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
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.entity.Player;
final class QuestAdminCommand implements CommandExecutor {
private static final String PERMISSION = "spigotquestboard.admin";
private final BoardRegistry registry;
QuestAdminCommand(BoardRegistry registry) {
this.registry = Objects.requireNonNull(registry, "registry");
}
@Override
public boolean onCommand(
CommandSender sender, Command command, String label, String[] arguments
) {
if (!sender.hasPermission(PERMISSION)) {
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");
return true;
}
if (!(sender instanceof Player player)) {
sender.sendMessage("A player must target the quest board block.");
return true;
}
Block target = player.getTargetBlockExact(5);
if (target == null) {
sender.sendMessage("Target a physical block within five blocks.");
return true;
}
try {
BoardRegistrationResult result = registry.register(RegisteredBoard.from(target));
sender.sendMessage(result == BoardRegistrationResult.CREATED
? "Quest board created."
: "That block is already a quest board.");
} catch (IOException exception) {
sender.sendMessage("The quest board could not be saved. No board was created.");
}
return true;
}
}
@@ -0,0 +1,28 @@
package games.dmg.spigotquestboard;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import java.util.List;
import net.kyori.adventure.text.Component;
import org.bukkit.entity.Player;
final class QuestBoardDialogUi implements QuestBoardUi {
@Override
public void open(Player player) {
DialogBase base = DialogBase.builder(Component.text("Quest Board"))
.externalTitle(Component.text("Quest Board"))
.body(List.of(DialogBody.plainMessage(
Component.text("No active quests or claimable items."), 420
)))
.canCloseWithEscape(true)
.pause(false)
.afterAction(DialogBase.DialogAfterAction.CLOSE)
.build();
Dialog dialog = Dialog.create(factory -> factory.empty()
.base(base)
.type(DialogType.notice()));
player.showDialog(dialog);
}
}
@@ -0,0 +1,31 @@
package games.dmg.spigotquestboard;
import java.util.Objects;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
final class QuestBoardInteractionListener implements Listener {
private final BoardRegistry registry;
private final QuestBoardUi ui;
QuestBoardInteractionListener(BoardRegistry registry, QuestBoardUi ui) {
this.registry = Objects.requireNonNull(registry, "registry");
this.ui = Objects.requireNonNull(ui, "ui");
}
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onPlayerInteract(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK
|| event.getHand() != EquipmentSlot.HAND
|| event.getClickedBlock() == null
|| !registry.contains(BoardId.from(event.getClickedBlock()))) {
return;
}
event.setCancelled(true);
ui.open(event.getPlayer());
}
}
@@ -0,0 +1,7 @@
package games.dmg.spigotquestboard;
import org.bukkit.entity.Player;
interface QuestBoardUi {
void open(Player player);
}
@@ -0,0 +1,15 @@
package games.dmg.spigotquestboard;
import java.util.Objects;
import org.bukkit.block.Block;
record RegisteredBoard(BoardId id, String worldName) {
RegisteredBoard {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(worldName, "worldName");
}
static RegisteredBoard from(Block block) {
return new RegisteredBoard(BoardId.from(block), block.getWorld().getName());
}
}
@@ -1,10 +1,34 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
import java.util.Objects;
import java.util.logging.Level;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotQuestBoardPlugin extends JavaPlugin {
@Override
public void onEnable() {
getLogger().info("Spigot Quest Board enabled.");
final BoardRegistry boards;
try {
boards = new BoardRegistry(new YamlBoardRepository(
getDataFolder().toPath().resolve("boards.yml")
));
} catch (IOException exception) {
getLogger().log(Level.SEVERE, "Could not load quest boards", exception);
getServer().getPluginManager().disablePlugin(this);
return;
}
QuestAdminCommand admin = new QuestAdminCommand(boards);
command("questadmin").setExecutor(admin);
getServer().getPluginManager().registerEvents(
new QuestBoardInteractionListener(boards, new QuestBoardDialogUi()), this
);
getLogger().info("Spigot Quest Board enabled with " + boards.size() + " boards.");
}
private PluginCommand command(String name) {
return Objects.requireNonNull(getCommand(name), "Missing command metadata for " + name);
}
}
@@ -0,0 +1,104 @@
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.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
final class YamlBoardRepository implements BoardRepository {
private final Path path;
YamlBoardRepository(Path path) {
this.path = path;
}
@Override
public BoardState load() throws IOException {
if (!Files.exists(path)) {
return BoardState.empty();
}
YamlConfiguration yaml = new YamlConfiguration();
try {
yaml.load(path.toFile());
} catch (InvalidConfigurationException exception) {
throw new IOException("Invalid quest board state", exception);
}
Set<RegisteredBoard> boards = new LinkedHashSet<>();
for (Map<?, ?> entry : yaml.getMapList("boards")) {
try {
UUID worldId = UUID.fromString(requiredString(entry, "world-id"));
String worldName = requiredString(entry, "world-name");
BoardId id = new BoardId(
worldId,
requiredInteger(entry, "x"),
requiredInteger(entry, "y"),
requiredInteger(entry, "z")
);
RegisteredBoard board = new RegisteredBoard(id, worldName);
boolean duplicate = boards.stream().anyMatch(existing -> existing.id().equals(id));
if (duplicate) {
throw new IOException("Duplicate quest board location: " + id);
}
boards.add(board);
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid quest board record", exception);
}
}
return new BoardState(boards);
}
@Override
public void save(BoardState state) throws IOException {
YamlConfiguration yaml = new YamlConfiguration();
List<Map<String, Object>> serialized = new ArrayList<>();
for (RegisteredBoard board : state.boards()) {
serialized.add(Map.of(
"world-id", board.id().worldId().toString(),
"world-name", board.worldName(),
"x", board.id().x(),
"y", board.id().y(),
"z", board.id().z()
));
}
yaml.set("boards", serialized);
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);
}
}
private static String requiredString(Map<?, ?> entry, String key) {
Object value = entry.get(key);
if (!(value instanceof String text) || text.isBlank()) {
throw new IllegalArgumentException("Missing " + key);
}
return text;
}
private static int requiredInteger(Map<?, ?> entry, String key) {
Object value = entry.get(key);
if (!(value instanceof Number number)) {
throw new IllegalArgumentException("Missing " + key);
}
return number.intValue();
}
}