feat(board): add readable physical board signs
Release / release (push) Successful in 2m29s
CI / build (push) Successful in 1m9s

This commit is contained in:
dmg
2026-09-05 09:26:45 -04:00
parent bb537b0556
commit 72e58383a2
18 changed files with 667 additions and 23 deletions
@@ -46,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()) {
@@ -1,5 +1,6 @@
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;
@@ -17,6 +18,13 @@ 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) {
@@ -55,14 +63,39 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld {
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);
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)) {
@@ -70,6 +103,16 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld {
}
}
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");
@@ -22,8 +22,8 @@ record PhysicalBoardPlan(
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);
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++) {
@@ -37,12 +37,32 @@ record PhysicalBoardPlan(
}
for (int lateral = -1; lateral <= 1; lateral++) {
BoardId location = offset(anchor, facing, lateral, 2, 1);
placements.add(new Placement(location, "OAK_WALL_SIGN"));
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
) {
@@ -54,14 +74,29 @@ record PhysicalBoardPlan(
);
}
record Placement(BoardId location, String material) {
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 "OAK_WALL_SIGN".equals(material);
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);
}
}
}
}
@@ -7,5 +7,9 @@ interface PhysicalBoardWorld {
void place(PhysicalBoardPlan.Placement placement, BoardFacing facing);
default boolean matches(PhysicalBoardPlan.Placement placement, BoardFacing facing) {
return false;
}
void restore(BoardId location, Object snapshot);
}
@@ -13,10 +13,15 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
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")
));
@@ -69,10 +74,17 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
);
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,