From 8f23241896e3072f438ed64cf69ac9f2bb937204 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 09:00:23 -0700 Subject: [PATCH 1/6] ci: bump publish-platforms pin to the multipart-JSON fix The pinned SHA (ca2dcd16, 2026-07-03) predates BentoBoxWorld/.github#13, which moved the CurseForge `metadata` and Hangar `versionUpload` JSON out of inline `-F` values and into files. curl treats `;` in an inline -F value as the start of a `type=`/`filename=` attribute, so a release body containing a semicolon truncates the JSON: the 1.26.3 publish failed with CurseForge "Error in field `metadata`: Invalid JSON" and Hangar "Failed to read request". Earlier releases only passed because their notes happened to contain no semicolons. Pin moved to master HEAD 1f91a0e, which contains the fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SAQ3YQkcfRoCZEwa6SPJcp --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1ee2a69..28ecf83 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ on: jobs: publish: - uses: bentoboxworld/.github/.github/workflows/publish-platforms.yml@ca2dcd167e8db4e0f671a976080744dda43801a6 # master + uses: bentoboxworld/.github/.github/workflows/publish-platforms.yml@1f91a0edf72e8c86d671b3b8fdd3121ac6fb88e1 # master with: use_release_asset: "true" # publish the jar attached to the release; do not rebuild hangar_slug: "AOneBlock" # blank = skip Hangar From 8da0a8f22f68477b020da71e357f2ed15fb5bd3b Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 2 Aug 2026 09:55:14 -0700 Subject: [PATCH 2/6] docs: rewrite the phase 0 comments to explain what the numbers mean 0_plains.yml is the reference file admins read first, but it never said what the numbers after a Material or EntityType are. An admin asked, and they were right to guess weighting: they are relative weights in a single raffle - not counts and not percentages - and blocks, mobs and custom-blocks all draw from that same pool, so a mob weight is directly comparable to a block weight and pushes every block's share down. The header now separates the three distinct kinds of number a phase file contains, since that is the real source of the confusion: 1. blocks/mobs/custom-blocks values are weights, one shared pool 2. fixedBlocks and holograms keys are positions within the phase, from 0 3. the top-level key is the section name - phases_index.yml has owned phase order and length since 1.26.0 Each section then gets its own explanation with the real Plains numbers worked out (11450 block weights + 665 mob weights = 12115, so GRASS_BLOCK: 2000 is 16.5% and COW: 150 is 1.2%), plus the CHEST two-stage roll, the mob/custom-block types that were missing from the list, and version gating. Comments only - every block, mob and weight is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017zwLryefnJ6wG76JmYNamx --- src/main/resources/phases/0_plains.yml | 240 ++++++++++++++++++++++--- 1 file changed, 218 insertions(+), 22 deletions(-) diff --git a/src/main/resources/phases/0_plains.yml b/src/main/resources/phases/0_plains.yml index bd21f74..f235a97 100644 --- a/src/main/resources/phases/0_plains.yml +++ b/src/main/resources/phases/0_plains.yml @@ -1,11 +1,107 @@ +# ============================================================================ +# AOneBlock phase file - PLAINS +# ============================================================================ +# This file is the reference example. Every option AOneBlock supports is +# documented here, so read this one first before editing the others. +# Full docs: https://docs.bentobox.world/en/latest/gamemodes/AOneBlock/Phases/ +# +# --------------------------------------------------------------------------- +# WHAT DO THE NUMBERS MEAN? +# --------------------------------------------------------------------------- +# There are three completely different kinds of number in a phase file. Which +# one you are looking at depends on which section it is in. +# +# 1. NUMBERS IN `blocks:`, `mobs:` AND `custom-blocks:` ARE **WEIGHTS**. +# +# They are NOT how many of that block will appear, and they are NOT a +# percentage. They are relative weights - tickets in a raffle. +# +# Every time the magic block is broken, AOneBlock adds up every weight in +# the phase and picks one entry at random, in proportion to its weight: +# +# chance of an entry = its weight / total of ALL weights in the phase +# +# IMPORTANT: `blocks:`, `mobs:` and `custom-blocks:` all share ONE pool. +# A mob weight and a block weight are directly comparable, and mob weights +# count towards the same total. Adding a mob makes every block slightly +# rarer, and vice versa. +# +# Worked example - the Winter phase: +# +# blocks: +# COBBLESTONE: 900 +# SAND: 100 +# DIRT: 200 +# STONE: 1000 +# SPRUCE_LEAVES: 500 +# +# The total is 900 + 100 + 200 + 1000 + 500 = 2700, so: +# +# STONE 1000 / 2700 = 37.0% of broken blocks +# COBBLESTONE 900 / 2700 = 33.3% +# SPRUCE_LEAVES 500 / 2700 = 18.5% +# DIRT 200 / 2700 = 7.4% +# SAND 100 / 2700 = 3.7% +# +# Because only the ratio matters, `STONE: 1000, DIRT: 200` behaves exactly +# the same as `STONE: 10, DIRT: 2`. Big numbers are used in the shipped +# files simply so you can add or tune a rare entry (say weight 5) without +# having to rescale everything else. +# +# Over a 1000-block phase you would therefore expect roughly 370 stone - +# but it is a fresh random roll each time, so the actual count varies. +# +# A weight must be a whole number of 1 or more. Weight 0 or a negative +# number is rejected with a warning in the server log. +# +# 2. NUMBERS USED AS KEYS IN `fixedBlocks:` AND `holograms:` ARE **POSITIONS**. +# +# They are the block count WITHIN this phase, counting from 0. So `0` is +# the very first block of the phase, `1` the second, and so on. They are +# not the player's overall block count. A position beyond the length of +# the phase is simply never reached. +# +# Fixed blocks are guaranteed - they bypass the weighted pool entirely. +# +# 3. THE TOP-LEVEL KEY OF THIS FILE ('0' BELOW) IS THE PHASE'S START BLOCK. +# +# Historically this was the overall block count at which the phase began, +# which is also why the shipped files are named `0_plains`, `2000_winter` +# and so on. Since 1.26.0 `phases_index.yml` is in charge of phase order +# and phase length, so this key is really just the section name - the +# index's `section:` field points at it. Custom phases may use any unique +# key, e.g. `my_phase:`. Keep the number if you like: it tells the index +# reconciler where a new file belongs in the running order. +# +# To change how long a phase lasts, edit `length` in `phases_index.yml` +# or use the admin phases GUI - not this key. +# ============================================================================ + '0': + # Display name of the phase. Shown in the phases GUI, the boss bar, logs and + # the [phase] command placeholder. name: Plains - # Icon in Phase GUI's. Icon uses BentoBox ItemParser: https://docs.bentobox.world/en/latest/BentoBox/ItemParser/ - # It supports Custom Player heads and any displayable item. + + # Icon in the Phases GUI. Uses the BentoBox ItemParser: + # https://docs.bentobox.world/en/latest/BentoBox/ItemParser/ + # It supports custom player heads and any displayable item. icon: GRASS_BLOCK - # List of blocks that will generate at these specific block counts. - # The numbers are relative to the phase and not the overall player's count. - # If you define 0 here, then firstBlock is not required and firstBlock will be replaced with this block. + + # ------------------------------------------------------------------------- + # fixedBlocks - guaranteed blocks at exact positions in the phase. + # ------------------------------------------------------------------------- + # KEY = position within this phase, counting from 0 (NOT a weight, and not + # the player's total block count). + # VALUE = a Bukkit Material, a CHEST_WITH_ shorthand, or a custom block + # definition (see custom-blocks further down). + # + # These always win over the random `blocks:` pool, so use them for scripted + # moments: the starting trees below, a guaranteed chest, a phase finale. + # Prefer blocks that do not need a supporting block - a torch or a sapling + # placed as the magic block will just pop off. + # + # If you define position 0 here, it replaces `firstBlock` and `firstBlock` + # is then not needed. fixedBlocks: 0: GRASS_BLOCK 1: GRASS_BLOCK @@ -13,17 +109,45 @@ 3: OAK_LOG 4: OAK_LOG 5: OAK_LOG + # CHEST_WITH_ places a chest holding one of that item - here the + # water bucket players need before the Ocean phase. 700: CHEST_WITH_WATER_BUCKET - # Hologram Lines to Display - # The First (Before Phase 1) Hologram is Located in your Locale. + + # ------------------------------------------------------------------------- + # holograms - text shown above the magic block at set positions. + # ------------------------------------------------------------------------- + # KEY = position within this phase, counting from 0 - same numbering as + # fixedBlocks above. + # VALUE = the text, with & colour codes. + # The very first hologram, shown before phase 1 starts, is in the locale file + # rather than here. holograms: 0: "&aGood Luck!" + + # ------------------------------------------------------------------------- + # biome - the biome of the magic block location only. + # ------------------------------------------------------------------------- + # This changes the biome at the magic block, not the whole island. To rebiome + # a whole island on phase change, use the Biomes addon from a start-command. + # An invalid name logs the full list of valid biomes on startup. biome: PLAINS + + # ------------------------------------------------------------------------- + # requiredMinecraftVersion - optional. Minimum Minecraft version this phase + # needs, e.g. '1.21.6'. Older servers skip the phase with a single log line + # instead of erroring on blocks or items they do not know. Set it in + # phases_index.yml too, and the file is not even parsed on an old server. + # Individual blocks and mobs can be gated as well - see `blocks:` below. + # ------------------------------------------------------------------------- + + # ------------------------------------------------------------------------- # Commands - # A list of commands can be run at the start and end of a phase. Commands are run as the Console - # unless the command is prefixed with [SUDO], then the command is run as the player - # triggering the commands. - # These placeholders in the command string will be replaced with the appropriate value: + # ------------------------------------------------------------------------- + # A list of commands can be run at the start and end of a phase. Commands are + # run as the Console unless the command is prefixed with [SUDO], then the + # command is run as the player triggering the commands. + # These placeholders in the command string will be replaced with the + # appropriate value: # [island] - Island name # [owner] - Island owner's name # [player] - The name of the player who broke the block triggering the commands @@ -42,14 +166,18 @@ # These are run only the first time a phase is completed # end-commands-first-time: # - 'broadcast &c&l[!] &b[player] &fhas completed the &d&n[phase]&f phase for the first time.' - # + + # ------------------------------------------------------------------------- # Requirements - # You can stipulate a set of requirements to start the phase: - # + # ------------------------------------------------------------------------- + # You can stipulate a set of requirements to start the phase. Until they are + # all met the player is held at the end of the previous phase. + # # economy-balance - the minimum player's economy balance (Requires Vault and an economy plugin) # bank-balance - the minimum island bank balance (requires Bank Addon) # level - the island level (Requires Levels Addon) # permission - a permission string + # cooldown - seconds that must pass since the phase was last started # # Example: # requirements: @@ -57,7 +185,43 @@ # level: 10 # permission: ready.for.battle # cooldown: 60 # seconds - + + # ------------------------------------------------------------------------- + # blocks - the weighted pool of blocks this phase can produce. + # ------------------------------------------------------------------------- + # KEY = a Bukkit Material that is a block. See + # https://hub.spigotmc.org/javadocs/bukkit/org/bukkit/Material.html + # VALUE = a WEIGHT (see the explanation at the top of this file). Not a + # count, not a percentage - just this entry's share of the pool. + # + # The weights below add up to 11450, and the `mobs:` weights add another 665, + # for a phase total of 12115. So in Plains: + # + # GRASS_BLOCK 2000 / 12115 = 16.5% of broken blocks + # CHEST 200 / 12115 = 1.7% + # DIAMOND_ORE 30 / 12115 = 0.25% + # EMERALD_ORE 10 / 12115 = 0.08% + # COW (a mob) 150 / 12115 = 1.2% + # + # To make something twice as common, double its weight. To add a new block + # without disturbing the existing balance much, give it a small weight - one + # of weight 100 added here would appear about 100/12215 = 0.8% of the time. + # + # CHEST is special: when CHEST is rolled, it is filled from the chest tables + # in 0_plains_chests.yml. The weight below is therefore the chance of getting + # *a* chest; which chest you get is a second, separate roll on rarity - + # COMMON 62%, UNCOMMON 25%, RARE 9%, EPIC 4% (these are fixed in code). + # + # An entry may also take an object form to gate it by server version: + # + # blocks: + # NETHERRACK: 300 + # DRIED_GHAST: + # weight: 25 + # requiredMinecraftVersion: '1.21.6' + # + # On an older server that entry is skipped and the rest of the phase loads + # normally. blocks: PODZOL: 40 MYCELIUM: 40 @@ -89,21 +253,33 @@ EMERALD_ORE: 10 DIRT_PATH: 100 COPPER_ORE: 200 - # Optional sibling list for custom entries. Lets you keep the map-form - # `blocks:` section above untouched while registering custom spawns. - # Entries here join the same weighted pool as `blocks:` — probabilities - # are directly comparable. Uncomment and tweak to try them out. + + # ------------------------------------------------------------------------- + # custom-blocks - optional sibling list for anything that is not a plain + # Material. Lets you keep the map-form `blocks:` section above untouched + # while registering custom spawns. + # ------------------------------------------------------------------------- + # Each entry has a `probability:` field. Despite the name it is a WEIGHT, + # exactly like the numbers in `blocks:` and `mobs:`, and it joins the very + # same pool - so `probability: 10` here is as likely as `SOME_BLOCK: 10` + # above. Uncomment and tweak to try them out. # # Supported custom types: - # - type: block — runs /setblock with full data (block states, + # - type: block - runs /setblock with full data (block states, # NBT, and an optional destroy|keep|replace mode). # Alias of `block-data`; prefer `block` for NBT. - # - type: mob-data — runs /summon with vanilla NBT/components. + # - type: block-data - as above, using plain block data. + # - type: mob - spawns a vanilla entity. Requires `mob`, and + # optionally `underlying-block` (default STONE). + # - type: mob-data - runs /summon with vanilla NBT/components. # Blocks inside the (scaled) bounding box are # cleared one tick after spawn so the mob fits. - # - type: mythic-mob — spawns a MythicMob via BentoBox's hook. + # - type: mythic-mob - spawns a MythicMob via BentoBox's hook. # Requires the MythicMobs plugin; otherwise # logged and skipped at runtime. + # - type: itemsadder - block from ItemsAdder. Requires `id`. + # - type: nexo - block from Nexo. Requires `id`. + # - type: craftengine - block from CraftEngine. Requires `id`. # # YAML caveat: because these data strings contain `{`, `}`, `[`, `]`, # and double quotes, wrap the value in SINGLE quotes so the inner @@ -130,6 +306,26 @@ # display-name: "Boss" # underlying-block: STONE # probability: 2 + + # ------------------------------------------------------------------------- + # mobs - the weighted pool of mobs this phase can produce. + # ------------------------------------------------------------------------- + # KEY = an EntityType that is alive and spawnable. See + # https://hub.spigotmc.org/javadocs/bukkit/org/bukkit/entity/EntityType.html + # VALUE = a WEIGHT, in the SAME pool as `blocks:` above. CHICKEN: 200 below + # is exactly as likely as a block of weight 200. + # + # When a mob is rolled the magic block becomes STONE if it was empty and the + # mob spawns on top of it. If `clear-blocks` is on in config.yml, blocks in + # the way are cleared so the mob fits. + # + # Mobs support the same object form as blocks for version gating: + # + # mobs: + # COW: 150 + # HAPPY_GHAST: + # weight: 5 + # requiredMinecraftVersion: '1.21.6' mobs: COW: 150 SPIDER: 75 From bc4a564f554315cca1cf2744bb0d792e41a75357 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 11:53:33 -0700 Subject: [PATCH 3/6] fix: do not lose island progress when the server restarts A player breaking blocks and then sitting through a restart would come back to their block count rolled back to the last checkpoint - up to 49 blocks of progress gone, repeatedly, on every restart. The shutdown save was queued, not written. onDisable() called saveCache(), which uses saveObjectAsync(), and this addon is a Pladdon: the server disables it before BentoBox, so the write landed in a queue that BentoBox had to drain on its way out. BentoBox 3.22.0 added that drain (flushAll), but every earlier version discarded it silently. Write directly on shutdown instead, via a new saveCacheNow() using saveObjectNow(). That removes the dependency on core behaviour entirely rather than relying on a specific BentoBox version getting it right. saveObjectNow() is BentoBox 3.22.0 API, so bump the dependency and raise api-version to match. Without the api-version bump the addon would still load on an older core and throw NoSuchMethodError at shutdown, which is worse than the bug being fixed. It now refuses to load with "Please update BentoBox". Also make the periodic save interval configurable as island.save-every, defaulting to 10 rather than the hardcoded 50. Nothing helps if the server is SIGKILLed, but this caps what an unclean kill can cost at 9 blocks. Reported on Discord: progress reverting on every restart, BentoBox 3.15.0. Test notes: the BentoBox bump broke 5 PhasesPanelTest tests, and fixing those exposed 2 more. None were regressions - all 7 called when(user.getTranslation(...)) on a real User rather than a mock, which stubs nothing on the User and instead attaches to whichever mock the real method last touched. 3.22.0's getTranslation(World, ...) calls getIWM().getAddon() first, moving the target. Added a stubTranslation() helper that stubs the LocalesManager these actually read from. The same pattern remains elsewhere in that class and is worth a follow-up sweep. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014t1DSo2wMbTWZLcwXpwUmQ --- pom.xml | 2 +- .../world/bentobox/aoneblock/AOneBlock.java | 5 +- .../world/bentobox/aoneblock/Settings.java | 26 ++++++++++ .../aoneblock/listeners/BlockListener.java | 21 +++++--- src/main/resources/addon.yml | 2 +- src/main/resources/config.yml | 5 ++ .../bentobox/aoneblock/SettingsTest.java | 29 +++++++++++ .../listeners/BlockListenerTest.java | 39 +++++++++++++- .../aoneblock/panels/PhasesPanelTest.java | 52 ++++++++++++------- 9 files changed, 151 insertions(+), 30 deletions(-) diff --git a/pom.xml b/pom.xml index ef8b554..458ea87 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ 5.11.0 4.110.0 - 3.15.0-SNAPSHOT + 3.22.0 4.0.10 1.8.0 0.0.67 diff --git a/src/main/java/world/bentobox/aoneblock/AOneBlock.java b/src/main/java/world/bentobox/aoneblock/AOneBlock.java index ea187e4..6d84d64 100644 --- a/src/main/java/world/bentobox/aoneblock/AOneBlock.java +++ b/src/main/java/world/bentobox/aoneblock/AOneBlock.java @@ -237,9 +237,10 @@ public boolean loadData() { @Override public void onDisable() { - // save cache + // Save cache. This must be a direct write, not a queued one: the server disables this + // Pladdon before BentoBox, so anything queued here depends on BentoBox draining it later. if (blockListener != null) { - blockListener.saveCache(); + blockListener.saveCacheNow(); } // Clear holograms diff --git a/src/main/java/world/bentobox/aoneblock/Settings.java b/src/main/java/world/bentobox/aoneblock/Settings.java index 70e0a8a..0652386 100644 --- a/src/main/java/world/bentobox/aoneblock/Settings.java +++ b/src/main/java/world/bentobox/aoneblock/Settings.java @@ -400,6 +400,13 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "island.water-mob-protection") private boolean waterMobProtection = true; + @ConfigComment("How often island progress is written to the database, in blocks broken") + @ConfigComment("Progress is also saved whenever a phase changes, a player logs out and the server shuts down,") + @ConfigComment("so this only decides how much is lost if the server dies without shutting down cleanly.") + @ConfigComment("Lower is safer but writes more often. Minimum is 1 (save every block)") + @ConfigEntry(path = "island.save-every") + private int saveEvery = 10; + @ConfigComment("Default max team size") @ConfigComment("Permission size cannot be less than the default below. ") @ConfigEntry(path = "island.max-team-size") @@ -1865,6 +1872,25 @@ public void setMobWarning(int mobWarning) { this.mobWarning = mobWarning; } + /** + * How many blocks are broken between periodic saves of island progress. + * A value below 1 would make the modulo check throw, so it is clamped. + * @return the saveEvery value, never less than 1 + */ + public int getSaveEvery() { + if (saveEvery < 1) { + saveEvery = 1; + } + return saveEvery; + } + + /** + * @param saveEvery the saveEvery to set + */ + public void setSaveEvery(int saveEvery) { + this.saveEvery = saveEvery; + } + /** * @return the waterMobProtection */ diff --git a/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java b/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java index 4f3410f..36e1d77 100644 --- a/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java +++ b/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java @@ -124,11 +124,6 @@ private record BrushSession(BukkitTask task, Block block) {} */ public static final int MAX_LOOK_AHEAD = 5; - /** - * How often island data is saved to the database (in blocks broken). - */ - public static final int SAVE_EVERY = 50; - /* * Loot tables for suspicious blocks */ @@ -161,11 +156,25 @@ public BlockListener(@NonNull AOneBlock addon) { /** * Saves all island data from the cache to the database asynchronously. + *

+ * Only safe while the server is running. On shutdown use {@link #saveCacheNow()}. */ public void saveCache() { cache.values().forEach(handler::saveObjectAsync); } + /** + * Saves all island data from the cache to the database on the calling thread. + *

+ * Used on shutdown, where an asynchronous save cannot be retried if it does not complete. + * BentoBox drains writes queued by addons as they are disabled, but this addon is a Pladdon, + * so the server disables it before BentoBox and that drain is the only thing standing between + * a queued block count and a rolled-back island. Writing directly removes the dependency. + */ + public void saveCacheNow() { + cache.values().forEach(handler::saveObjectNow); + } + // --------------------------------------------------------------------- // Section: Listeners // --------------------------------------------------------------------- @@ -448,7 +457,7 @@ private ProcessPhaseResult processPhase(Cancellable e, Island i, OneBlockIslands return new ProcessPhaseResult(phase, true, 0); } handleNewPhase(player, i, is, phase, block, prevPhaseName); - } else if (is.getBlockNumber() % SAVE_EVERY == 0) { + } else if (is.getBlockNumber() % addon.getSettings().getSaveEvery() == 0) { // Periodically save the island's progress. saveIsland(i); } diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 203743b..dbfb578 100755 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -1,7 +1,7 @@ name: AOneBlock main: world.bentobox.aoneblock.AOneBlock version: ${version}${build.number} -api-version: 3.13.0 +api-version: 3.22.0 metrics: true icon: "STONE" repository: "BentoBoxWorld/AOneBlock" diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d339659..c366d19 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -404,6 +404,11 @@ island: mob-warning: 5 # Whether spawned mobs that need water to survive will spawn in a generated water block water-mob-protection: true + # How often island progress is written to the database, in blocks broken + # Progress is also saved whenever a phase changes, a player logs out and the server shuts down, + # so this only decides how much is lost if the server dies without shutting down cleanly. + # Lower is safer but writes more often. Minimum is 1 (save every block) + save-every: 10 # Default max team size # Permission size cannot be less than the default below. max-team-size: 4 diff --git a/src/test/java/world/bentobox/aoneblock/SettingsTest.java b/src/test/java/world/bentobox/aoneblock/SettingsTest.java index 0238e34..1f94692 100644 --- a/src/test/java/world/bentobox/aoneblock/SettingsTest.java +++ b/src/test/java/world/bentobox/aoneblock/SettingsTest.java @@ -1732,6 +1732,35 @@ void testSetHologramDuration() { s.setHologramDuration(2345); assertEquals(2345, s.getHologramDuration()); } + + /** + * Test method for {@link world.bentobox.aoneblock.Settings#getSaveEvery()}. + */ + @Test + void testGetSaveEveryDefault() { + assertEquals(10, s.getSaveEvery()); + } + + /** + * Test method for {@link world.bentobox.aoneblock.Settings#setSaveEvery(int)}. + */ + @Test + void testSetSaveEvery() { + s.setSaveEvery(25); + assertEquals(25, s.getSaveEvery()); + } + + /** + * The value is used as a modulo divisor, so anything below 1 has to be clamped or + * the block break handler would throw an ArithmeticException on every block. + */ + @Test + void testGetSaveEveryClampsZeroAndBelow() { + s.setSaveEvery(0); + assertEquals(1, s.getSaveEvery()); + s.setSaveEvery(-50); + assertEquals(1, s.getSaveEvery()); + } diff --git a/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest.java b/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest.java index 81b1dac..fcd087c 100644 --- a/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest.java +++ b/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest.java @@ -5,6 +5,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.File; @@ -49,6 +51,8 @@ public class BlockListenerTest extends CommonTestSetup { // Class under test private BlockListener bl; + private AbstractDatabaseHandler h; + @Mock AOneBlock addon; @Mock @@ -78,13 +82,14 @@ public class BlockListenerTest extends CommonTestSetup { public void setUp() throws Exception { super.setUp(); // This has to be done beforeClass otherwise the tests will interfere with each other - AbstractDatabaseHandler h = mock(AbstractDatabaseHandler.class); + h = mock(AbstractDatabaseHandler.class); // Database MockedStatic mockDb = Mockito.mockStatic(DatabaseSetup.class); DatabaseSetup dbSetup = mock(DatabaseSetup.class); mockDb.when(DatabaseSetup::getDatabase).thenReturn(dbSetup); when(dbSetup.getHandler(any())).thenReturn(h); when(h.saveObject(any())).thenReturn(CompletableFuture.completedFuture(true)); + when(h.saveObjectNow(any())).thenReturn(CompletableFuture.completedFuture(true)); // Addon when(addon.getPlugin()).thenReturn(plugin); @@ -187,4 +192,36 @@ void testOnBlockFromToCenterBlock() { assertTrue(e.isCancelled()); } + /** + * Test method for {@link world.bentobox.aoneblock.listeners.BlockListener#saveCache()}. + */ + @Test + void testSaveCacheQueuesTheWrite() throws Exception { + island.setUniqueId(UUID.randomUUID().toString()); + bl.getIsland(island); + + bl.saveCache(); + + verify(h).saveObject(any()); + verify(h, never()).saveObjectNow(any()); + } + + /** + * The shutdown save has to write directly. This addon is a Pladdon, so the server disables it + * before BentoBox, and a queued write only lands if BentoBox drains the queue afterwards - + * which older BentoBox versions did not do, silently rolling islands back on every restart. + * + * Test method for {@link world.bentobox.aoneblock.listeners.BlockListener#saveCacheNow()}. + */ + @Test + void testSaveCacheNowWritesDirectly() throws Exception { + island.setUniqueId(UUID.randomUUID().toString()); + bl.getIsland(island); + + bl.saveCacheNow(); + + verify(h).saveObjectNow(any()); + verify(h, never()).saveObject(any()); + } + } diff --git a/src/test/java/world/bentobox/aoneblock/panels/PhasesPanelTest.java b/src/test/java/world/bentobox/aoneblock/panels/PhasesPanelTest.java index 850b1f5..06b6bf8 100644 --- a/src/test/java/world/bentobox/aoneblock/panels/PhasesPanelTest.java +++ b/src/test/java/world/bentobox/aoneblock/panels/PhasesPanelTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; @@ -71,6 +72,24 @@ class PhasesPanelTest extends CommonTestSetup { private PhasesPanel panel; + /** + * Sets what a locale reference translates to for these tests. + *

+ * The {@code user} here is a real {@link User} wrapping a mock player, not a mock, so + * {@code when(user.getTranslation(...))} does not stub anything on it - it runs the real + * method and Mockito attaches the stub to whichever mock that method happened to touch last. + * That is an implementation detail of BentoBox and moves between versions. Stub the + * {@link world.bentobox.bentobox.managers.LocalesManager} that {@code getTranslation} actually + * reads from instead, which is stable. + * + * @param reference locale key, without any addon prefix + * @param value what it should translate to + */ + private void stubTranslation(String reference, String value) { + when(lm.get(any(), eq(reference))).thenReturn(value); + when(lm.get(any(), eq("aoneblock." + reference))).thenReturn(value); + } + private void setUpAddonMocks() { when(addon.getPlugin()).thenReturn(plugin); when(addon.getOneBlockManager()).thenReturn(oneBlockManager); @@ -325,10 +344,9 @@ void testBuildBlocksText() throws Exception { OneBlockPhase phase = createTestPhase("Plains"); - when(user.getTranslation("aoneblock.gui.buttons.phase.blocks-prefix")).thenReturn("Blocks: "); - when(user.getTranslation("aoneblock.gui.buttons.phase.wrap-at")).thenReturn("50"); - when(user.getTranslation("aoneblock.gui.buttons.phase.blocks", "name", "Stone")).thenReturn("Stone, "); - when(user.getTranslation("aoneblock.gui.buttons.phase.blocks", "name", "Dirt")).thenReturn("Dirt, "); + stubTranslation("aoneblock.gui.buttons.phase.blocks-prefix", "Blocks: "); + stubTranslation("aoneblock.gui.buttons.phase.wrap-at", "50"); + stubTranslation("aoneblock.gui.buttons.phase.blocks", "[name], "); when(hooksManager.getHook("LangUtils")).thenReturn(Optional.empty()); mockedUtil.when(() -> Util.prettifyText(anyString())).thenAnswer(i -> { String arg = i.getArgument(0); @@ -827,7 +845,7 @@ void testCollectTooltipsWithRealTooltip() throws Exception { new ItemTemplateRecord.ActionRecords(ClickType.LEFT, "SELECT", "content", "tooltip.key") ); - when(user.getTranslation(world, "tooltip.key")).thenReturn("Real tooltip"); + stubTranslation("tooltip.key", "Real tooltip"); Method method = PhasesPanel.class.getDeclaredMethod("collectTooltips", List.class); method.setAccessible(true); @@ -1482,8 +1500,8 @@ void testCollectTooltipsAllBlank() throws Exception { new ItemTemplateRecord.ActionRecords(ClickType.LEFT, "VIEW", "content", "tooltip2") ); - when(user.getTranslation(world, "tooltip1")).thenReturn(" "); // Blank after translation - when(user.getTranslation(world, "tooltip2")).thenReturn(""); // Empty + stubTranslation("tooltip1", " "); // Blank after translation + stubTranslation("tooltip2", ""); // Empty Method method = PhasesPanel.class.getDeclaredMethod("collectTooltips", List.class); method.setAccessible(true); @@ -2144,8 +2162,7 @@ void testBuildDescriptionTextTemplatedWithBiome() throws Exception { try (MockedStatic ms = mockStatic(LangUtilsHook.class)) { ms.when(() -> LangUtilsHook.getBiomeName(biome, user)).thenReturn("Plains"); - when(user.getTranslationOrNothing("custom.desc", "number", "0", "[biome]", "Plains", "[bank]", "", "[economy]", "", "[level]", "", "[permission]", "", "[blocks]", "")) - .thenReturn("Plains Description"); + stubTranslation("custom.desc", "[biome] Description"); Method method = PhasesPanel.class.getDeclaredMethod("buildDescriptionText", ItemTemplateRecord.class, OneBlockPhase.class, reqTextClass, String.class); method.setAccessible(true); @@ -2179,9 +2196,8 @@ void testBuildDefaultDescription() throws Exception { reqConstructor.setAccessible(true); Object reqTexts = reqConstructor.newInstance("", "", "", ""); - when(user.getTranslationOrNothing("aoneblock.gui.buttons.phase.starting-block", "number", "0")).thenReturn("Block 0"); - when(user.getTranslationOrNothing("aoneblock.gui.buttons.phase.description", "[starting-block]", "Block 0", "[biome]", "", "[bank]", "", "[economy]", "", "[level]", "", "[permission]", "", "[blocks]", "")) - .thenReturn("Default Desc"); + stubTranslation("aoneblock.gui.buttons.phase.starting-block", "Block [number]"); + stubTranslation("aoneblock.gui.buttons.phase.description", "Default Desc [starting-block]"); Method method = PhasesPanel.class.getDeclaredMethod("buildDefaultDescription", OneBlockPhase.class, reqTextClass, String.class); method.setAccessible(true); @@ -2398,10 +2414,9 @@ void testBuildDefaultDescriptionWithBiome() throws Exception { reqConstructor.setAccessible(true); Object reqTexts = reqConstructor.newInstance("", "", "", ""); - when(user.getTranslationOrNothing("aoneblock.gui.buttons.phase.starting-block", "number", "0")).thenReturn("Block 0"); - when(user.getTranslationOrNothing("aoneblock.gui.buttons.phase.biome", "[biome]", "Plains")).thenReturn("Biome: Plains"); - when(user.getTranslationOrNothing("aoneblock.gui.buttons.phase.description", "[starting-block]", "Block 0", "[biome]", "Biome: Plains", "[bank]", "", "[economy]", "", "[level]", "", "[permission]", "", "[blocks]", "")) - .thenReturn("Description with biome"); + stubTranslation("aoneblock.gui.buttons.phase.starting-block", "Block [number]"); + stubTranslation("aoneblock.gui.buttons.phase.biome", "Biome: [biome]"); + stubTranslation("aoneblock.gui.buttons.phase.description", "Description with biome [biome]"); try (MockedStatic ms = mockStatic(LangUtilsHook.class)) { ms.when(() -> LangUtilsHook.getBiomeName(biome, user)).thenReturn("Plains"); @@ -2440,9 +2455,8 @@ void testBuildDescriptionTextNullTemplate() throws Exception { reqConstructor.setAccessible(true); Object reqTexts = reqConstructor.newInstance("", "", "", ""); - when(user.getTranslationOrNothing("aoneblock.gui.buttons.phase.starting-block", "number", "0")).thenReturn("Block 0"); - when(user.getTranslationOrNothing("aoneblock.gui.buttons.phase.description", "[starting-block]", "Block 0", "[biome]", "", "[bank]", "", "[economy]", "", "[level]", "", "[permission]", "", "[blocks]", "")) - .thenReturn("Default Description"); + stubTranslation("aoneblock.gui.buttons.phase.starting-block", "Block [number]"); + stubTranslation("aoneblock.gui.buttons.phase.description", "Default Description [starting-block]"); Method method = PhasesPanel.class.getDeclaredMethod("buildDescriptionText", ItemTemplateRecord.class, OneBlockPhase.class, reqTextClass, String.class); method.setAccessible(true); From c55d10c096a559c032ea955cc5031c18b0a1d9b8 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:48:37 -0700 Subject: [PATCH 4/6] 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")); + } + } From f5d3131c5a166502f1cfa00b94ce4e2f5614ee0b Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:51:13 -0700 Subject: [PATCH 5/6] ci: build on JDK 25 so BentoBox 3.22.0 class files can be read BentoBox 3.18.0 onwards is compiled for Java 25 (Minecraft 26.x), so its class files are version 69. A JDK 21 javac cannot parse those at all, and the build died with "class file has wrong version 69.0, should be 65.0" against every BentoBox type before it reached any of our code. This only surfaced once the dependency moved to 3.22.0. It did not show up locally because the dev machine is already on JDK 25 - the compiler reads the newer class files happily and 21 still emits Java 21 bytecode, which is what the addon ships. The addon's own target is unchanged: still Java 21 via in the pom. Only the JDK doing the compiling moves. Also moves setup-java to v4 and the 'adopt' distribution to 'temurin', since neither v3 nor adopt offers a Java 25 build. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014t1DSo2wMbTWZLcwXpwUmQ --- .github/workflows/build.yml | 12 ++++++++---- .github/workflows/modrinth-publish.yml | 7 ++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e88943..23d614a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,11 +14,15 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Set up JDK 21 - uses: actions/setup-java@v3 + # BentoBox 3.18.0+ is compiled for Java 25 (Minecraft 26.x), so its class files + # cannot be read by a JDK 21 javac at all - the build fails with + # "class file has wrong version 69.0, should be 65.0" before reaching our code. + # The addon itself still targets 21 via in the pom. + - name: Set up JDK 25 + uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-version: 21 + distribution: 'temurin' + java-version: 25 - name: Cache SonarCloud packages uses: actions/cache@v3 with: diff --git a/.github/workflows/modrinth-publish.yml b/.github/workflows/modrinth-publish.yml index 960aa3a..667c352 100644 --- a/.github/workflows/modrinth-publish.yml +++ b/.github/workflows/modrinth-publish.yml @@ -22,11 +22,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - # 2. Set up Java 21 (required by AOneBlock' build) - - name: Set up Java 21 + # 2. Set up Java 25 - required to read BentoBox 3.18.0+ class files, which are + # compiled for Java 25. The addon itself still targets 21 via in the pom. + - name: Set up Java 25 uses: actions/setup-java@v4 with: - java-version: '21' + java-version: '25' distribution: 'temurin' # 3. Cache Maven dependencies to speed up builds From 8ec83adf3f1719b5c75f9ec2a57a9819b0d1080d Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:52:13 -0700 Subject: [PATCH 6/6] Update build version to 1.27.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ef8b554..96c36f4 100644 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,7 @@ -LOCAL - 1.26.3 + 1.27.0 BentoBoxWorld_AOneBlock bentobox-world