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 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 diff --git a/pom.xml b/pom.xml index ef8b554..e00a5ea 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 @@ -67,7 +67,7 @@ -LOCAL - 1.26.3 + 1.27.0 BentoBoxWorld_AOneBlock bentobox-world 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/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/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/main/resources/phases/0_plains.yml b/src/main/resources/phases/0_plains.yml index bd21f74..afda882 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,51 @@ 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. 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: 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 +172,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 +191,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 +259,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 +312,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 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/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")); + } + } 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);