feat(board): generate physical oak quest boards
Release / release (push) Successful in 3m8s
CI / build (push) Successful in 1m17s

This commit is contained in:
dmg
2026-09-05 09:00:18 -04:00
parent 2624c2bc02
commit bb537b0556
18 changed files with 903 additions and 26 deletions
@@ -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;
@@ -0,0 +1,79 @@
package games.dmg.spigotquestboard;
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 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");
}
for (int line = 0; line < 4; line++) {
sign.getSide(Side.FRONT).line(line, DECORATIVE_TEXT);
}
if (!sign.update(true, false)) {
throw new IllegalStateException("Could not configure physical quest-board sign");
}
}
@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);
}
}
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,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,67 @@
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<>(23);
Set<BoardId> interactions = new LinkedHashSet<>(15);
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"));
interactions.add(location);
}
return new PhysicalBoardPlan(facing, placements, interactions);
}
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) {
Placement {
Objects.requireNonNull(location, "location");
Objects.requireNonNull(material, "material");
}
boolean sign() {
return "OAK_WALL_SIGN".equals(material);
}
}
}
@@ -0,0 +1,11 @@
package games.dmg.spigotquestboard;
interface PhysicalBoardWorld {
boolean isEmpty(BoardId location);
Object snapshot(BoardId location);
void place(PhysicalBoardPlan.Placement placement, BoardFacing facing);
void restore(BoardId location, Object snapshot);
}
@@ -15,10 +15,26 @@ 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, 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
@@ -32,13 +48,19 @@ final class QuestAdminCommand implements CommandExecutor, TabCompleter {
if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) {
return updatePlayerCommands(sender, arguments[1]);
}
if (arguments.length != 1 || !"createboard".equalsIgnoreCase(arguments[0])) {
sender.sendMessage(
"Usage: /questadmin createboard | /questadmin commands enable|disable"
);
if (arguments.length == 1 && "createboard".equalsIgnoreCase(arguments[0])) {
createBoard(sender);
return true;
}
createBoard(sender);
if (arguments.length == 2
&& "createboard".equalsIgnoreCase(arguments[0])
&& "physical".equalsIgnoreCase(arguments[1])) {
createPhysicalBoard(sender);
return true;
}
sender.sendMessage(
"Usage: /questadmin createboard [physical] | /questadmin commands enable|disable"
);
return true;
}
@@ -84,6 +106,36 @@ final class QuestAdminCommand implements CommandExecutor, TabCompleter {
}
}
private void createPhysicalBoard(CommandSender sender) {
if (!(sender instanceof Player player)) {
sender.sendMessage("A player must target the ground anchor block.");
return;
}
Block target = player.getTargetBlockExact(5);
if (target == null) {
sender.sendMessage("Target a ground anchor within five blocks.");
return;
}
try {
PhysicalBoardCreationResult result = physicalBoards.create(
target, BoardFacing.towardPlayer(player.getFacing())
);
switch (result) {
case CREATED -> sender.sendMessage("Physical quest board created.");
case OBSTRUCTED -> sender.sendMessage(
"The physical quest board needs 23 empty placement blocks."
);
case ALREADY_REGISTERED -> sender.sendMessage(
"A physical quest-board interaction location is already registered."
);
}
} catch (IOException | IllegalArgumentException exception) {
sender.sendMessage(
"The physical quest board could not be created. All changed blocks were restored."
);
}
}
@Override
public List<String> onTabComplete(
CommandSender sender, Command command, String alias, String[] arguments
@@ -97,6 +149,9 @@ final class QuestAdminCommand implements CommandExecutor, TabCompleter {
if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) {
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();
}
+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: