From c55d10c096a559c032ea955cc5031c18b0a1d9b8 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:48:37 -0700 Subject: [PATCH] feat: support MiniMessage and hex colours in phase text An admin coloured a phase hologram with ... and got white text with the tags shown literally. Holograms deserialized with LegacyComponentSerializer.legacyAmpersand(), which understands the 16 legacy & codes and nothing else - Adventure builds that instance with hexColours=false, so even &#RRGGBB did not work. The action bar used a second, separately configured serializer that did support hex but not MiniMessage. Two display paths, two different answers to "what formatting can I use here", neither documented where anyone would look. Both now go through Util.parseMiniMessageOrLegacy, which accepts MiniMessage, & and section codes, hex, and any mixture, and is cached on the BentoBox side. This also fixes section codes being rendered as literal text. Translations come back from User.getTranslation already converted to section codes, so anything locale-sourced was being handed to a serializer bound to '&' - the starting hologram (aoneblock.island.starting-hologram) and the action bar both took that path. Phase file hologram lines never see the translation layer, which is why the reported case showed raw MiniMessage tags rather than raw section codes. The boss bar title is untouched: it uses the String-based Bukkit BossBar API and BentoBox has already resolved its formatting by then. Existing configs are unaffected. BentoBox's MiniMessage instance is the non-strict one, so text containing stray angle brackets is left as literal text rather than throwing. Documented the accepted syntax in the phase file, next to the holograms section where the admin was already looking. Util.parseMiniMessageOrLegacy is BentoBox 3.2.0 API, so this needs no dependency bump. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014t1DSo2wMbTWZLcwXpwUmQ --- .../aoneblock/listeners/BossBarListener.java | 22 +++--- .../aoneblock/listeners/HoloListener.java | 7 +- src/main/resources/phases/0_plains.yml | 8 ++- .../listeners/BossBarListenerTest.java | 70 +++++++++++++++++++ .../aoneblock/listeners/HoloListenerTest.java | 66 +++++++++++++++++ 5 files changed, 159 insertions(+), 14 deletions(-) diff --git a/src/main/java/world/bentobox/aoneblock/listeners/BossBarListener.java b/src/main/java/world/bentobox/aoneblock/listeners/BossBarListener.java index ece5aa2..e804ccf 100644 --- a/src/main/java/world/bentobox/aoneblock/listeners/BossBarListener.java +++ b/src/main/java/world/bentobox/aoneblock/listeners/BossBarListener.java @@ -19,7 +19,6 @@ import org.eclipse.jdt.annotation.NonNull; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import world.bentobox.aoneblock.AOneBlock; import world.bentobox.aoneblock.dataobjects.OneBlockIslands; import world.bentobox.aoneblock.events.MagicBlockEvent; @@ -28,6 +27,7 @@ import world.bentobox.bentobox.api.events.island.IslandExitEvent; import world.bentobox.bentobox.api.metadata.MetaDataValue; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; import world.bentobox.bentobox.database.objects.Island; public class BossBarListener implements Listener { @@ -35,11 +35,6 @@ public class BossBarListener implements Listener { private static final String AONEBLOCK_BOSSBAR = "aoneblock.bossbar"; public static final String AONEBLOCK_ACTIONBAR = "aoneblock.actionbar"; - private static final LegacyComponentSerializer LEGACY_SERIALIZER = LegacyComponentSerializer.builder() - .character('&') - .hexColors() // Enables support for modern hex codes (e.g., &#FF0000) alongside legacy codes. - .build(); - public BossBarListener(AOneBlock addon) { super(); this.addon = addon; @@ -78,16 +73,21 @@ public void onFlagChange(FlagSettingChangeEvent e) { } /** - * Converts a string containing Bukkit color codes ('&') into an Adventure Component. + * Converts a formatted string into an Adventure Component. + *

+ * Accepts MiniMessage tags, {@code &} or {@code §} legacy codes, hex ({@code &#RRGGBB}), or a + * mixture of them. Handling {@code §} matters here because translations arrive already + * converted to {@code §} codes by BentoBox - a serializer bound to {@code &} would leave those + * in the output as literal text. * - * @param legacyString The string with Bukkit color and format codes. + * @param text The string with color and format codes. * @return The resulting Adventure Component. */ - public static Component bukkitToAdventure(String legacyString) { - if (legacyString == null) { + public static Component bukkitToAdventure(String text) { + if (text == null) { return Component.empty(); } - return LEGACY_SERIALIZER.deserialize(legacyString); + return Util.parseMiniMessageOrLegacy(text); } private void tryToShowActionBar(UUID uuid, Island island) { diff --git a/src/main/java/world/bentobox/aoneblock/listeners/HoloListener.java b/src/main/java/world/bentobox/aoneblock/listeners/HoloListener.java index 5ceff03..9e29080 100644 --- a/src/main/java/world/bentobox/aoneblock/listeners/HoloListener.java +++ b/src/main/java/world/bentobox/aoneblock/listeners/HoloListener.java @@ -15,7 +15,6 @@ import org.bukkit.util.Vector; import org.eclipse.jdt.annotation.NonNull; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import world.bentobox.aoneblock.AOneBlock; import world.bentobox.bentobox.util.Util; import world.bentobox.aoneblock.dataobjects.OneBlockIslands; @@ -131,6 +130,10 @@ private Location getHologramLocation(Island island) { /** * Creates a new hologram (TextDisplay) at the given location. * Caches the hologram for future reference. + *

+ * The text may use MiniMessage tags, {@code &} or {@code §} legacy codes, hex + * ({@code &#RRGGBB}), or a mixture. Phase file hologram lines are read straight from YAML and + * never see BentoBox's translation, so this is the only place their formatting is resolved. * * @param pos the location to create the hologram at * @param text the text to display @@ -140,7 +143,7 @@ private void createHologram(Location pos, String text) { display.setAlignment(TextDisplay.TextAlignment.CENTER); display.setBillboard(Billboard.CENTER); display.setPersistent(true); - display.text(LegacyComponentSerializer.legacyAmpersand().deserialize(text)); + display.text(Util.parseMiniMessageOrLegacy(text)); activeHolograms.add(pos); } diff --git a/src/main/resources/phases/0_plains.yml b/src/main/resources/phases/0_plains.yml index f235a97..afda882 100644 --- a/src/main/resources/phases/0_plains.yml +++ b/src/main/resources/phases/0_plains.yml @@ -118,7 +118,13 @@ # ------------------------------------------------------------------------- # KEY = position within this phase, counting from 0 - same numbering as # fixedBlocks above. - # VALUE = the text, with & colour codes. + # VALUE = the text. Any of these work, and they can be mixed: + # &a&lGood Luck! legacy colour and format codes + # 7FF55Good Luck! hex colour + # Good Luck! MiniMessage tags + # MiniMessage also gives you gradients, e.g. + # Good Luck! + # Use \n for a line break. # The very first hologram, shown before phase 1 starts, is in the locale file # rather than here. holograms: diff --git a/src/test/java/world/bentobox/aoneblock/listeners/BossBarListenerTest.java b/src/test/java/world/bentobox/aoneblock/listeners/BossBarListenerTest.java index 8bc4bec..599fec6 100644 --- a/src/test/java/world/bentobox/aoneblock/listeners/BossBarListenerTest.java +++ b/src/test/java/world/bentobox/aoneblock/listeners/BossBarListenerTest.java @@ -1,5 +1,7 @@ package world.bentobox.aoneblock.listeners; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; @@ -21,6 +23,9 @@ import org.mockito.Mock; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import world.bentobox.aoneblock.AOneBlock; import world.bentobox.aoneblock.CommonTestSetup; import world.bentobox.aoneblock.Settings; @@ -141,4 +146,69 @@ void testBossBarNotShownWhenDisabledInConfig() { mockedBukkit.verify(() -> Bukkit.createBossBar(anyString(), any(), any()), never()); verify(bossBar, never()).addPlayer(any()); } + + /** + * Serializes to legacy section codes so a test can assert on the formatting that actually + * comes out, without depending on how the component tree happens to be nested. + */ + private static String legacy(Component c) { + return LegacyComponentSerializer.legacySection().serialize(c); + } + + /** + * MiniMessage tags used to be rendered as literal text because the serializer only understood + * legacy codes. + */ + @Test + void testBukkitToAdventureParsesMiniMessage() { + String result = legacy(BossBarListener.bukkitToAdventure("Plains")); + assertEquals("Plains", PlainTextComponentSerializer.plainText() + .serialize(BossBarListener.bukkitToAdventure("Plains"))); + assertTrue(result.contains("\u00a7a"), "expected green in " + result); + assertTrue(result.contains("\u00a7l"), "expected bold in " + result); + } + + /** + * MiniMessage gradients, which legacy codes cannot express at all. + */ + @Test + void testBukkitToAdventureParsesGradient() { + Component c = BossBarListener.bukkitToAdventure("Plains"); + assertEquals("Plains", PlainTextComponentSerializer.plainText().serialize(c)); + } + + /** + * Translations reach this method already converted to section codes by BentoBox, so a + * serializer bound to '&' would leave them in the output as literal text. + */ + @Test + void testBukkitToAdventureParsesSectionCodes() { + Component c = BossBarListener.bukkitToAdventure("§aPlains"); + assertEquals("Plains", PlainTextComponentSerializer.plainText().serialize(c)); + } + + /** + * Legacy '&' codes must keep working - every existing locale file uses them. + */ + @Test + void testBukkitToAdventureParsesLegacyAmpersand() { + Component c = BossBarListener.bukkitToAdventure("&aPlains"); + assertEquals("Plains", PlainTextComponentSerializer.plainText().serialize(c)); + assertTrue(legacy(c).contains("\u00a7a"), "expected green in " + legacy(c)); + } + + /** + * Hex colours, which the previous serializer supported here and must not regress. + */ + @Test + void testBukkitToAdventureParsesHex() { + Component c = BossBarListener.bukkitToAdventure("7FF55Plains"); + assertEquals("Plains", PlainTextComponentSerializer.plainText().serialize(c)); + assertEquals(TextColor.fromHexString("#55FF55"), c.color()); + } + + @Test + void testBukkitToAdventureNullIsEmpty() { + assertEquals(Component.empty(), BossBarListener.bukkitToAdventure(null)); + } } diff --git a/src/test/java/world/bentobox/aoneblock/listeners/HoloListenerTest.java b/src/test/java/world/bentobox/aoneblock/listeners/HoloListenerTest.java index 2b1dfd9..df9e99f 100644 --- a/src/test/java/world/bentobox/aoneblock/listeners/HoloListenerTest.java +++ b/src/test/java/world/bentobox/aoneblock/listeners/HoloListenerTest.java @@ -1,6 +1,8 @@ package world.bentobox.aoneblock.listeners; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; @@ -24,6 +26,12 @@ import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.entity.TextDisplay; +import org.mockito.ArgumentCaptor; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.util.Vector; import org.eclipse.jdt.annotation.NonNull; import org.junit.jupiter.api.AfterEach; @@ -198,4 +206,62 @@ void testProcess() { verify(sch).runTaskLater(isNull(), any(Runnable.class), anyLong()); } + /** + * Captures the component the hologram was actually given. + */ + private Component displayed(String hologramLine) { + when(phase.getHologramLine(anyInt())).thenReturn(hologramLine); + // process() writes the line to the data object then reads it straight back, and that + // object is a mock, so the read has to be stubbed too or it returns the setUp default. + when(is.getHologram()).thenReturn(hologramLine); + hl.process(island, is, phase); + ArgumentCaptor captor = ArgumentCaptor.forClass(Component.class); + verify(hologram).text(captor.capture()); + return captor.getValue(); + } + + /** + * Phase file hologram lines are read straight from YAML, so this is the only place their + * formatting is resolved. MiniMessage tags used to appear as literal text. + */ + @Test + void testHologramParsesMiniMessage() { + Component c = displayed("Plains"); + assertEquals("Plains", PlainTextComponentSerializer.plainText().serialize(c)); + String legacy = LegacyComponentSerializer.legacySection().serialize(c); + assertTrue(legacy.contains("\u00a7a"), "expected green in " + legacy); + assertTrue(legacy.contains("\u00a7l"), "expected bold in " + legacy); + } + + /** + * Legacy '&' codes must keep working - every existing phase file uses them. + */ + @Test + void testHologramParsesLegacyAmpersand() { + Component c = displayed("&aGood Luck!"); + assertEquals("Good Luck!", PlainTextComponentSerializer.plainText().serialize(c)); + assertTrue(LegacyComponentSerializer.legacySection().serialize(c).contains("\u00a7a")); + } + + /** + * Hex was not supported here before - the serializer was built without hex enabled. + */ + @Test + void testHologramParsesHex() { + Component c = displayed("7FF55Good Luck!"); + assertEquals("Good Luck!", PlainTextComponentSerializer.plainText().serialize(c)); + assertEquals(TextColor.fromHexString("#55FF55"), c.color()); + } + + /** + * The starting hologram comes from the locale file via User.getTranslation, which hands back + * section codes. A serializer bound to '&' left those in as literal text. + */ + @Test + void testHologramParsesSectionCodes() { + Component c = displayed("\u00a7aWelcome"); + assertEquals("Welcome", PlainTextComponentSerializer.plainText().serialize(c)); + assertTrue(LegacyComponentSerializer.legacySection().serialize(c).contains("\u00a7a")); + } + }