fix(board): make readable signs glow
Release / release (push) Successful in 2m26s
CI / build (push) Successful in 1m4s

This commit is contained in:
dmg
2026-09-05 09:36:21 -04:00
parent 72e58383a2
commit 07061a3622
8 changed files with 225 additions and 14 deletions
+1 -1
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
+7
View File
@@ -98,3 +98,10 @@ 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`.
@@ -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
@@ -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);
} }
@@ -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(
@@ -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);
} }
} }
} }