diff --git a/build.gradle.kts b/build.gradle.kts index 3c4a888d8..c2dd75be3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,7 +46,7 @@ paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArt group = "world.bentobox" // From // Base properties from -val buildVersion = "3.14.1" +val buildVersion = "3.14.2" val buildNumberDefault = "-LOCAL" // Local build identifier val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version diff --git a/src/main/java/world/bentobox/bentobox/api/flags/Flag.java b/src/main/java/world/bentobox/bentobox/api/flags/Flag.java index 0b9680388..3033d197c 100644 --- a/src/main/java/world/bentobox/bentobox/api/flags/Flag.java +++ b/src/main/java/world/bentobox/bentobox/api/flags/Flag.java @@ -797,8 +797,10 @@ public Builder hideWhen(HideWhen hideWhen) { * @return Flag */ public Flag build() { - // Ensure the default rank is not below the minimum selectable rank - if (defaultRank < minimumRank) { + // Ensure the default rank is not below the minimum selectable rank. + // Only applies to PROTECTION flags — SETTING/WORLD_SETTING flags use -1 + // as a valid "disabled" state (Island.isAllowed checks >= 0). + if (type == Type.PROTECTION && defaultRank < minimumRank) { BentoBox.getInstance().logWarning("Flag " + id + " defaultRank (" + defaultRank + ") is below minimumRank (" + minimumRank + "); raising defaultRank to minimumRank."); defaultRank = minimumRank; diff --git a/src/main/java/world/bentobox/bentobox/managers/LocalesManager.java b/src/main/java/world/bentobox/bentobox/managers/LocalesManager.java index 02b89b010..0bbb91b8c 100644 --- a/src/main/java/world/bentobox/bentobox/managers/LocalesManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/LocalesManager.java @@ -272,9 +272,28 @@ public void loadLocalesFromFile(String localeFolder) { String tag = language.getName().substring(0, language.getName().length() - 4); Locale localeObject = Locale.forLanguageTag(tag); - // Skip files whose name does not parse to a real BCP-47 language tag. - // e.g. "zh_CN.yml" (underscore) yields Locale.ROOT, which would otherwise show - // up as a blank entry in the language selector panel. + // If the tag uses underscores (e.g. pt_BR) it won't parse as a valid BCP-47 tag. + // Silently fix it: rename the file to use '-' and load it under the corrected locale. + if (localeObject.getLanguage().isEmpty() && tag.contains("_")) { + String fixedTag = tag.replace('_', '-'); + File fixedFile = new File(language.getParentFile(), fixedTag + ".yml"); + if (!fixedFile.exists() && language.renameTo(fixedFile)) { + plugin.logWarning("Locale file '" + localeFolder + "/" + language.getName() + + "' has been renamed to '" + fixedTag + ".yml' to conform to BCP-47 (use '-' not '_')."); + language = fixedFile; + } else if (fixedFile.exists()) { + plugin.logWarning("Duplicate locale file '" + localeFolder + "/" + language.getName() + + "': '" + fixedTag + ".yml' already exists and will be used instead. Please remove the underscore version."); + continue; + } else { + plugin.logWarning("Locale file '" + localeFolder + "/" + language.getName() + + "' uses '_' instead of '-' and could not be renamed; loading it as '" + fixedTag + "'."); + } + tag = fixedTag; + localeObject = Locale.forLanguageTag(tag); + } + + // Skip files that still don't parse to a real BCP-47 language tag after the fix attempt. if (localeObject.getLanguage().isEmpty()) { plugin.logWarning("Ignoring locale file '" + localeFolder + "/" + language.getName() + "': '" + tag + "' is not a valid BCP-47 language tag (use '-' not '_')."); diff --git a/src/main/java/world/bentobox/bentobox/util/Util.java b/src/main/java/world/bentobox/bentobox/util/Util.java index d62c3d736..2d27e0b83 100644 --- a/src/main/java/world/bentobox/bentobox/util/Util.java +++ b/src/main/java/world/bentobox/bentobox/util/Util.java @@ -98,6 +98,13 @@ public class Util { */ private static final Pattern LEGACY_HEX_CODE_PATTERN = Pattern.compile("&#[0-9a-fA-F]{3,6}|\u00A7x(\u00A7[0-9a-fA-F]){6}"); + /** + * Pattern to match the BungeeCord/Spigot {@code &x&R&R&G&G&B&B} hex format + * (after {@code §} has been normalised to {@code &}). + * Produced by {@link LegacyComponentSerializer} with {@code useUnusualXRepeatedCharacterHexFormat()}. + */ + private static final Pattern BUNGEE_HEX_PATTERN = Pattern.compile("&x(&[0-9a-fA-F]){6}"); + /** * MiniMessage instance for parsing MiniMessage-formatted strings. */ @@ -1047,6 +1054,10 @@ public static String legacyToMiniMessage(@NonNull String legacy) { // First, normalize § to & for uniform processing String text = legacy.replace('\u00A7', '&'); + // Convert BungeeCord/Spigot §x§R§R§G§G§B§B hex format (now &x&R&R...) to &#RRGGBB + // so the HEX_PATTERN step below handles all hex input uniformly. + text = normalizeBungeeHex(text); + // Convert hex codes &#RRGGBB → Matcher hexMatcher = HEX_PATTERN.matcher(text); StringBuilder sb = new StringBuilder(); @@ -1211,6 +1222,8 @@ public static String legacyToMiniMessage(@NonNull String legacy) { public static String replaceLegacyCodesInline(@NonNull String text) { // Normalize § to & text = text.replace('\u00A7', '&'); + // Convert BungeeCord/Spigot &x&R&R&G&G&B&B hex format to &#RRGGBB (see legacyToMiniMessage) + text = normalizeBungeeHex(text); // Replace hex codes &#RRGGBB → Matcher hexMatcher = HEX_PATTERN.matcher(text); StringBuilder sb = new StringBuilder(); @@ -1532,6 +1545,30 @@ private static String legacyColorCode(TextColor color) { return COLOR_CHAR + Character.toString(code); } + /** + * Converts the BungeeCord/Spigot {@code &x&R&R&G&G&B&B} repeated-character hex format + * (produced after {@code §} → {@code &} normalisation) to the {@code &#RRGGBB} form + * that {@link #HEX_PATTERN} understands. + * + * @param text input string with {@code &} normalised from {@code §} + * @return string with {@code &x&R&R&G&G&B&B} sequences replaced by {@code &#RRGGBB} + */ + private static String normalizeBungeeHex(@NonNull String text) { + Matcher m = BUNGEE_HEX_PATTERN.matcher(text); + if (!m.find()) { + return text; + } + StringBuilder sb = new StringBuilder(); + m.reset(); + while (m.find()) { + // "&x&2&3&8&a&f&0" → strip "&x" prefix and remaining "&" chars → "238af0" + String digits = m.group(0).substring(2).replace("&", ""); + m.appendReplacement(sb, "&#" + digits); + } + m.appendTail(sb); + return sb.toString(); + } + /** * Serializes an Adventure Component to plain text with no formatting. * diff --git a/src/main/java/world/bentobox/bentobox/versions/ServerCompatibility.java b/src/main/java/world/bentobox/bentobox/versions/ServerCompatibility.java index 6ed4fe903..825e058b4 100644 --- a/src/main/java/world/bentobox/bentobox/versions/ServerCompatibility.java +++ b/src/main/java/world/bentobox/bentobox/versions/ServerCompatibility.java @@ -173,7 +173,11 @@ public enum ServerVersion { /** * @since 3.12.2 */ - V26_1_1(Compatibility.COMPATIBLE),; + V26_1_1(Compatibility.COMPATIBLE), + /** + * @since 3.14.2 + */ + V26_1_2(Compatibility.COMPATIBLE),; private final Compatibility compatibility; diff --git a/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java b/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java index 4dc4f167e..0746be2e7 100644 --- a/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java +++ b/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java @@ -450,4 +450,17 @@ void testDefaultRankClampedToMinimumRank() { .build(); assertEquals(RanksManager.MEMBER_RANK, flag.getDefaultRank()); } + + /** + * SETTING flags should allow -1 (disabled) as defaultRank without clamping, + * since Island.isAllowed() uses >= 0 as the enabled threshold. + */ + @Test + void testSettingFlagAllowsNegativeDefaultRank() { + Flag flag = new Flag.Builder("pvp_test", Material.ARROW) + .type(Flag.Type.SETTING) + .defaultRank(-1) + .build(); + assertEquals(-1, flag.getDefaultRank()); + } } diff --git a/src/test/java/world/bentobox/bentobox/util/LegacyToMiniMessageTest.java b/src/test/java/world/bentobox/bentobox/util/LegacyToMiniMessageTest.java index d76f72119..61569d90f 100644 --- a/src/test/java/world/bentobox/bentobox/util/LegacyToMiniMessageTest.java +++ b/src/test/java/world/bentobox/bentobox/util/LegacyToMiniMessageTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import net.kyori.adventure.text.Component; @@ -285,4 +286,45 @@ void testStripSpaceAfterColorCodesRespectsBoundary() { // Reset must NOT strip assertEquals("\u00A7r world", Util.stripSpaceAfterColorCodes("\u00A7r world")); } + + /** + * Regression for BentoBox#2943: + * hex colors using the {@code &#RRGGBB} format were broken because + * {@code translateColorCodes} serialises them to the BungeeCord + * {@code §x§R§R§G§G§B§B} format, which {@code legacyToMiniMessage} (and + * {@code replaceLegacyCodesInline}) did not recognise. The colour was then + * corrupted to a sequence of named colours (&2, &3, …) instead of + * the intended hex value. + */ + @Test + void testBungeeCordHexFormatRoundTrip() { + // Simulate the full path: user writes îaf0&l in a locale file. + // convertToLegacy (pure-legacy path) calls translateColorCodes which + // serialises to §x§2§3§8§a§f§0§l. sendRawMessage then calls + // parseMiniMessageOrLegacy which must reconstruct the original colour. + String bungeeHex = "\u00A7x\u00A72\u00A73\u00A78\u00A7a\u00A7f\u00A70\u00A7l"; + Component component = Util.parseMiniMessageOrLegacy(bungeeHex + "test"); + // The component must carry the original hex colour, not a named-colour approximation. + net.kyori.adventure.text.format.TextColor color = component.children().isEmpty() + ? component.color() + : component.children().get(0).color(); + assertNotNull(color, "Expected a colour on the component"); + assertEquals(0x238af0, color.value(), "Hex colour #238af0 must survive the BungeeCord round-trip"); + } + + /** + * {@code &#RRGGBB&l} (the raw user-written format) must also round-trip correctly + * through {@code legacyToMiniMessage}. + */ + @Test + void testRawHexWithBoldRoundTrip() { + String mm = Util.legacyToMiniMessage("îaf0&lBold text"); + Component component = Util.parseMiniMessage(mm); + // Must have the correct hex colour + net.kyori.adventure.text.format.TextColor color = component.color() != null + ? component.color() + : component.children().isEmpty() ? null : component.children().get(0).color(); + assertNotNull(color, "Expected a colour"); + assertEquals(0x238af0, color.value()); + } }