diff --git a/README.md b/README.md index 4d28261..dbad118 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The approved behavior is specified in the [OKF knowledge bundle](knowledge/index ## 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 ` 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 `. 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 ` 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 `. 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 diff --git a/knowledge/log.md b/knowledge/log.md index f2f97c5..bf8e600 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -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. - 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`. + +## 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`. diff --git a/knowledge/user-stories/us-011-add-readable-physical-board-signage.md b/knowledge/user-stories/us-011-add-readable-physical-board-signage.md index 154ce05..ecae79c 100644 --- a/knowledge/user-stories/us-011-add-readable-physical-board-signage.md +++ b/knowledge/user-stories/us-011-add-readable-physical-board-signage.md @@ -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-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] 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 diff --git a/src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java b/src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java index 6627bf2..fb4fddb 100644 --- a/src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java +++ b/src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java @@ -13,6 +13,7 @@ import org.bukkit.block.Sign; import org.bukkit.block.data.BlockData; import org.bukkit.block.data.type.WallSign; import org.bukkit.block.sign.Side; +import org.bukkit.block.sign.SignSide; final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld { 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"); } List lines = signLines(placement.signKind()); + SignSide front = sign.getSide(Side.FRONT); 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)) { throw new IllegalStateException("Could not configure physical quest-board sign"); } @@ -74,6 +77,14 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld { @Override 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()); Material material = Material.matchMaterial(placement.material()); if (material == null || block.getType() != material) { @@ -87,9 +98,13 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld { || !(block.getState() instanceof Sign sign)) { return false; } + SignSide front = sign.getSide(Side.FRONT); + if (front.isGlowingText() != glowingText) { + return false; + } List expected = signLines(placement.signKind()); 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; } } diff --git a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardUpgrader.java b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardUpgrader.java index b701cb9..e8822a0 100644 --- a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardUpgrader.java +++ b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardUpgrader.java @@ -32,14 +32,21 @@ final class PhysicalBoardUpgrader { int upgraded = 0; Map registered = registry.registeredBoards(); for (Candidate candidate : candidates(registered)) { - if (!hasLegacyRegistrations(candidate, registered)) { + boolean legacy = hasLegacyRegistrations(candidate, registered); + boolean full = hasFullRegistrations(candidate, registered); + if (!legacy && !full) { continue; } PhysicalBoardWorld world = worlds.open(candidate.anchor().worldId()); - if (world != null && isLegacyBoard(candidate, world) - && upgrade(candidate, world)) { + if (world == null) { + continue; + } + if (legacy && isLegacyBoard(candidate, world) && upgradeLegacy(candidate, world)) { upgraded++; registered = registry.registeredBoards(); + } else if (full && isNonGlowingFullBoard(candidate, world)) { + refreshReadableSigns(candidate, world); + upgraded++; } } return upgraded; @@ -60,6 +67,19 @@ final class PhysicalBoardUpgrader { ); } + private static boolean hasFullRegistrations( + Candidate candidate, Map 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) { PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing()); for (PhysicalBoardPlan.Placement placement : legacyPlacements(plan)) { @@ -75,8 +95,21 @@ final class PhysicalBoardUpgrader { return true; } - private boolean upgrade(Candidate candidate, PhysicalBoardWorld world) throws IOException { - List signs = newSigns( + private boolean isNonGlowingFullBoard(Candidate candidate, PhysicalBoardWorld world) { + 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 signs = readableSigns( PhysicalBoardPlan.create(candidate.anchor(), candidate.facing()) ); Map snapshots = new LinkedHashMap<>(); @@ -153,7 +186,30 @@ final class PhysicalBoardUpgrader { return Set.copyOf(locations); } + private void refreshReadableSigns(Candidate candidate, PhysicalBoardWorld world) + throws IOException { + List signs = readableSigns( + PhysicalBoardPlan.create(candidate.anchor(), candidate.facing()) + ); + Map 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 newSigns(PhysicalBoardPlan plan) { + return readableSigns(plan); + } + + private static List readableSigns(PhysicalBoardPlan plan) { return plan.placements().stream().filter(placement -> placement.signKind() == PhysicalBoardSignKind.TITLE || placement.signKind() == PhysicalBoardSignKind.INSTRUCTION diff --git a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java index ccbd694..2b10318 100644 --- a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java +++ b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java @@ -11,5 +11,11 @@ interface PhysicalBoardWorld { return false; } + default boolean matches( + PhysicalBoardPlan.Placement placement, BoardFacing facing, boolean glowingText + ) { + return false; + } + void restore(BoardId location, Object snapshot); } diff --git a/src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java b/src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java index 163bac8..937b86f 100644 --- a/src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java +++ b/src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java @@ -89,6 +89,34 @@ final class BukkitPhysicalBoardWorldTest { 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 void rendersReadableTitleAndInstructionText() { assertEquals( diff --git a/src/test/java/games/dmg/spigotquestboard/PhysicalBoardUpgraderTest.java b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardUpgraderTest.java index e280eae..0378c07 100644 --- a/src/test/java/games/dmg/spigotquestboard/PhysicalBoardUpgraderTest.java +++ b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardUpgraderTest.java @@ -35,6 +35,33 @@ final class PhysicalBoardUpgraderTest { 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 void doesNotAlterCustomBoardsOrStructuresThatDoNotExactlyMatch() throws Exception { RegisteredBoard custom = new RegisteredBoard( @@ -58,6 +85,23 @@ final class PhysicalBoardUpgraderTest { 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 void doesNotOverwriteAnOccupiedNewSignCell() throws Exception { RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false); @@ -89,6 +133,19 @@ final class PhysicalBoardUpgraderTest { 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 void rollsBackBothCellsWhenAtomicPersistenceFails() throws Exception { RecordingRepository repository = new RecordingRepository(legacyRegistrations(), true); @@ -120,6 +177,12 @@ final class PhysicalBoardUpgraderTest { .collect(Collectors.toSet()); } + private static Set fullRegistrations() { + return plan().interactionLocations().stream() + .map(location -> new RegisteredBoard(location, "survival")) + .collect(Collectors.toSet()); + } + private static FakeWorld legacyWorld() { FakeWorld world = new FakeWorld(); plan().placements().stream().filter(placement -> @@ -132,6 +195,19 @@ final class PhysicalBoardUpgraderTest { 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 newSigns() { return plan().placements().stream().filter(placement -> placement.signKind() == PhysicalBoardSignKind.TITLE @@ -140,8 +216,13 @@ final class PhysicalBoardUpgraderTest { } private static final class FakeWorld implements PhysicalBoardWorld { + private record Snapshot( + PhysicalBoardPlan.Placement placement, BoardFacing facing, Boolean glowing + ) { } + private final Map existing = new HashMap<>(); private final Map facings = new HashMap<>(); + private final Map glowing = new HashMap<>(); private final Map placed = new HashMap<>(); private int placements; private int failPlacement = -1; @@ -151,7 +232,9 @@ final class PhysicalBoardUpgraderTest { } @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) { @@ -160,6 +243,8 @@ final class PhysicalBoardUpgraderTest { } existing.put(placement.location(), placement); facings.put(placement.location(), facing); + glowing.put(placement.location(), + placement.signKind() != PhysicalBoardSignKind.DECORATIVE); placed.put(placement.location(), placement); } @@ -170,13 +255,25 @@ final class PhysicalBoardUpgraderTest { && (!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) { - if (snapshot == null) { - existing.remove(location); - facings.remove(location); + Snapshot captured = (Snapshot) snapshot; + restore(existing, location, captured.placement()); + restore(facings, location, captured.facing()); + restore(glowing, location, captured.glowing()); + } + + private static void restore(Map values, BoardId location, T value) { + if (value == null) { + values.remove(location); } else { - PhysicalBoardPlan.Placement placement = (PhysicalBoardPlan.Placement) snapshot; - existing.put(location, placement); + values.put(location, value); } } }