feat(board): add shared physical quest boards
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user