From 59ba578607dfa5d61283390eb173d1aad6beffa7 Mon Sep 17 00:00:00 2001 From: MrRockis <30273291+MrRockis@users.noreply.github.com> Date: Thu, 27 Jun 2024 22:33:58 +0300 Subject: [PATCH 01/44] Remove NMS Version Check (#124) Removes the NmsManager checks for goPaint tool interaction Fixes the error on 1.20.5/6 servers --- .../net/arcaniax/gopaint/GoPaintPlugin.java | 3 - .../gopaint/listeners/InteractListener.java | 9 +-- .../gopaint/objects/other/NmsManager.java | 61 ------------------- 3 files changed, 2 insertions(+), 71 deletions(-) delete mode 100644 src/main/java/net/arcaniax/gopaint/objects/other/NmsManager.java diff --git a/src/main/java/net/arcaniax/gopaint/GoPaintPlugin.java b/src/main/java/net/arcaniax/gopaint/GoPaintPlugin.java index f4ba1534..952bd913 100644 --- a/src/main/java/net/arcaniax/gopaint/GoPaintPlugin.java +++ b/src/main/java/net/arcaniax/gopaint/GoPaintPlugin.java @@ -23,7 +23,6 @@ import net.arcaniax.gopaint.listeners.ConnectListener; import net.arcaniax.gopaint.listeners.InteractListener; import net.arcaniax.gopaint.listeners.InventoryListener; -import net.arcaniax.gopaint.objects.other.NmsManager; import net.arcaniax.gopaint.objects.other.Settings; import net.arcaniax.gopaint.objects.player.PlayerBrushManager; import net.arcaniax.gopaint.utils.DisabledBlocks; @@ -40,7 +39,6 @@ public class GoPaintPlugin extends JavaPlugin implements Listener { private static final int BSTATS_ID = 10557; public static boolean plotSquaredEnabled; - public static NmsManager nmsManager; private static PlayerBrushManager manager; private static GoPaintPlugin goPaintPlugin; private static Settings settings; @@ -88,7 +86,6 @@ public void onEnable() { pm.registerEvents(inventoryListener, this); pm.registerEvents(cmdHandler, this); getCommand("gopaint").setExecutor(cmdHandler); - nmsManager = new NmsManager(); DisabledBlocks.addBlocks(); // Check if we are in a safe environment ServerLib.checkUnsafeForks(); diff --git a/src/main/java/net/arcaniax/gopaint/listeners/InteractListener.java b/src/main/java/net/arcaniax/gopaint/listeners/InteractListener.java index 8cb2f103..b8a4e52a 100644 --- a/src/main/java/net/arcaniax/gopaint/listeners/InteractListener.java +++ b/src/main/java/net/arcaniax/gopaint/listeners/InteractListener.java @@ -23,7 +23,6 @@ import net.arcaniax.gopaint.objects.player.PlayerBrush; import net.arcaniax.gopaint.utils.XMaterial; import org.bukkit.Location; -import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -32,8 +31,6 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.EquipmentSlot; -import java.util.Set; - public class InteractListener implements Listener { public GoPaintPlugin plugin; @@ -45,10 +42,8 @@ public InteractListener(GoPaintPlugin main) { @SuppressWarnings("deprecation") @EventHandler(priority = EventPriority.LOWEST) public void onClick(PlayerInteractEvent e) { - if (GoPaintPlugin.nmsManager.isAtLeastVersion(1, 9, 0)) { - if (e.getHand() == EquipmentSlot.OFF_HAND) { - return; - } + if (e.getHand() == EquipmentSlot.OFF_HAND) { + return; } if (!e.getPlayer().hasPermission("gopaint.use")) { return; diff --git a/src/main/java/net/arcaniax/gopaint/objects/other/NmsManager.java b/src/main/java/net/arcaniax/gopaint/objects/other/NmsManager.java deleted file mode 100644 index 20227245..00000000 --- a/src/main/java/net/arcaniax/gopaint/objects/other/NmsManager.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * goPaint is designed to simplify painting inside of Minecraft. - * Copyright (C) Arcaniax-Development - * Copyright (C) Arcaniax team and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package net.arcaniax.gopaint.objects.other; - -import net.arcaniax.gopaint.GoPaintPlugin; - -public class NmsManager { - - String version; - - public NmsManager() { - String a = GoPaintPlugin.getGoPaintPlugin().getServer().getClass().getPackage().getName(); - version = a.substring(a.lastIndexOf('.') + 1); - } - - public boolean isVersion(String v) { - return version.equalsIgnoreCase(v); - } - - public boolean isVersion(int gameV, int releaseV, int subReleaseV) { - return version.equalsIgnoreCase("v" + gameV + "_" + releaseV + "_R" + subReleaseV); - } - - public boolean isAtLeastVersion(int gameV, int releaseV, int subReleaseV) { - String[] split = version.split("_"); - int game = Integer.parseInt(split[0].toLowerCase().replace("v", "")); - int release = Integer.parseInt(split[1]); - int subRelease = Integer.parseInt(split[2].toLowerCase().replace("r", "")); - - if (game > gameV) { - return true; - } else if (game < gameV) { - return false; - } else { - if (release > releaseV) { - return true; - } else if (release < releaseV) { - return false; - } else { - return subRelease >= subReleaseV; - } - } - } - -} From f355fd43415ca9616e7e48a7b2f61b37cb698e9c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 1 Aug 2024 01:15:20 +0000 Subject: [PATCH 02/44] Update dependency gradle to v8.9 --- gradle/wrapper/gradle-wrapper.jar | Bin 43453 -> 43504 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 5 ++++- gradlew.bat | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e6441136f3d4ba8a0da8d277868979cfbc8ad796..2c3521197d7c4586c843d1d3e9090525f1898cde 100644 GIT binary patch delta 8703 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 Ojj7^59?Sdh$^Qpp*~F>< delta 8662 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4413138..09523c0e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index b740cf13..f5feea6d 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 7101f8e4..9b42019c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## From 474467cca3acc16f31744f7f53423af57ac456ec Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Sep 2024 01:04:10 +0000 Subject: [PATCH 03/44] Update dependency org.bstats:bstats-base to v3.0.3 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 5d959d4f..3e1607e1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,7 +55,7 @@ dependencies { compileOnlyApi("com.fastasyncworldedit:FastAsyncWorldEdit-Bukkit") implementation("dev.notmyfault.serverlib:ServerLib") implementation("org.bstats:bstats-bukkit:3.0.2") - implementation("org.bstats:bstats-base:3.0.2") + implementation("org.bstats:bstats-base:3.0.3") implementation("io.papermc:paperlib") } From 5065de1e62bf0d1b4202edb2fa8faece5445ed75 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Sep 2024 03:17:37 +0000 Subject: [PATCH 04/44] Update dependency org.bstats:bstats-bukkit to v3.0.3 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 3e1607e1..d0d24dcc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,7 +54,7 @@ dependencies { compileOnly("com.mojang:authlib:1.5.25") compileOnlyApi("com.fastasyncworldedit:FastAsyncWorldEdit-Bukkit") implementation("dev.notmyfault.serverlib:ServerLib") - implementation("org.bstats:bstats-bukkit:3.0.2") + implementation("org.bstats:bstats-bukkit:3.0.3") implementation("org.bstats:bstats-base:3.0.3") implementation("io.papermc:paperlib") } From dc541fa3d9867bfd9dd9645976bca27c5aef5f17 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Sep 2024 03:20:09 +0000 Subject: [PATCH 05/44] Update dependency gradle to v8.10 --- gradle/wrapper/gradle-wrapper.jar | Bin 43504 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 2c3521197d7c4586c843d1d3e9090525f1898cde..a4b76b9530d66f5e68d973ea569d8e19de379189 100644 GIT binary patch delta 3990 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0e..9355b415 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From de3061d4e6d0e6c20599be33c95c6e24abdf0cba Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Sep 2024 03:20:13 +0000 Subject: [PATCH 06/44] Update gradle/actions action to v4 --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc529d40..5b02c129 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,7 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@v3 + uses: gradle/actions/wrapper-validation@v4 - name : Grab SHA uses : benjlevesque/short-sha@v3.0 id : short-sha From db5d43576309262bb2dcad46a38e69ce703727cb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 00:31:09 +0000 Subject: [PATCH 07/44] Update dependency gradle to v8.10.2 --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9355b415..df97d72b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From aa410c71e360d8d72ed595428210a5b8a3927d64 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 00:31:13 +0000 Subject: [PATCH 08/44] Update dependency org.bstats:bstats-base to v3.1.0 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index d0d24dcc..fc361d2d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,7 +55,7 @@ dependencies { compileOnlyApi("com.fastasyncworldedit:FastAsyncWorldEdit-Bukkit") implementation("dev.notmyfault.serverlib:ServerLib") implementation("org.bstats:bstats-bukkit:3.0.3") - implementation("org.bstats:bstats-base:3.0.3") + implementation("org.bstats:bstats-base:3.1.0") implementation("io.papermc:paperlib") } From 2fc240f2ab2f27ffdb89464d04773eb0a29513e2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 06:11:32 +0000 Subject: [PATCH 09/44] Update dependency org.bstats:bstats-bukkit to v3.1.0 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index fc361d2d..eddf22bc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,7 +54,7 @@ dependencies { compileOnly("com.mojang:authlib:1.5.25") compileOnlyApi("com.fastasyncworldedit:FastAsyncWorldEdit-Bukkit") implementation("dev.notmyfault.serverlib:ServerLib") - implementation("org.bstats:bstats-bukkit:3.0.3") + implementation("org.bstats:bstats-bukkit:3.1.0") implementation("org.bstats:bstats-base:3.1.0") implementation("io.papermc:paperlib") } From 0c386d116fac94b25578a291c9d8a5d2ac0ba846 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 1 Nov 2024 02:03:01 +0000 Subject: [PATCH 10/44] Update plugin org.ajoberstar.grgit to v5.3.0 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index eddf22bc..3b19c040 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { id("com.diffplug.spotless") version "6.25.0" id("com.github.johnrengelman.shadow") version "8.1.1" - id("org.ajoberstar.grgit") version "5.2.2" + id("org.ajoberstar.grgit") version "5.3.0" id("net.minecrell.plugin-yml.bukkit") version "0.6.0" idea From 0cc843d2ce4e1afc77cfe06906a04f2e3759fe99 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 2 Nov 2024 22:29:28 +0100 Subject: [PATCH 11/44] fix: skulls after 1.20.5 --- .../net/arcaniax/gopaint/utils/Items.java | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/arcaniax/gopaint/utils/Items.java b/src/main/java/net/arcaniax/gopaint/utils/Items.java index 3cae58c1..b46aae41 100644 --- a/src/main/java/net/arcaniax/gopaint/utils/Items.java +++ b/src/main/java/net/arcaniax/gopaint/utils/Items.java @@ -36,18 +36,17 @@ public Items() { } public ItemStack create(Material mat, short data, int amount, String name, String lore) { - ItemStack is = new ItemStack(mat); - is.setAmount(amount); + ItemStack is = new ItemStack(mat, amount); ItemMeta meta = is.getItemMeta(); - if (!lore.equals("")) { + if (!lore.isEmpty()) { String[] loreListArray = lore.split("___"); - List loreList = new ArrayList(); + List loreList = new ArrayList<>(); for (String s : loreListArray) { loreList.add(s.replace("&", "§")); } meta.setLore(loreList); } - if (!name.equals("")) { + if (!name.isEmpty()) { meta.setDisplayName(name.replace("&", "§")); } is.setItemMeta(meta); @@ -65,15 +64,15 @@ public ItemStack createHead(String data, int amount, String name, String lore) { } item.setAmount(amount); ItemMeta meta = item.getItemMeta(); - if (lore != "") { + if (!lore.isEmpty()) { String[] loreListArray = lore.split("___"); - List loreList = new ArrayList(); + List loreList = new ArrayList<>(); for (String s : loreListArray) { loreList.add(s.replace("&", "§")); } meta.setLore(loreList); } - if (!name.equals("")) { + if (!name.isEmpty()) { meta.setDisplayName(name.replace("&", "§")); } item.setItemMeta(meta); @@ -81,11 +80,15 @@ public ItemStack createHead(String data, int amount, String name, String lore) { SkullMeta headMeta = (SkullMeta) item.getItemMeta(); GameProfile profile = new GameProfile(UUID.randomUUID(), "goPaint"); profile.getProperties().put("textures", new Property("textures", data)); - Field profileField = null; try { - profileField = headMeta.getClass().getDeclaredField("profile"); + Field profileField = headMeta.getClass().getDeclaredField("profile"); profileField.setAccessible(true); - profileField.set(headMeta, profile); + if (profileField.getType() == GameProfile.class) { + profileField.set(headMeta, profile); + } else { + Class resolvableProfileClass = Class.forName("net.minecraft.world.item.component.ResolvableProfile"); + profileField.set(headMeta, resolvableProfileClass.getConstructor(GameProfile.class).newInstance(profile)); + } } catch (Exception ignored) { } item.setItemMeta(headMeta); From 0274d2a0dff4b4cb4e99a6fdc0db48603144cb79 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Dec 2024 04:14:23 +0000 Subject: [PATCH 12/44] Update dependency gradle to v8.11.1 (#135) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df97d72b..e2847c82 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From ec6e3bafbc5839ba9680459aeac19c01d80ff370 Mon Sep 17 00:00:00 2001 From: Arcaniax Date: Tue, 28 Oct 2025 14:57:22 +0100 Subject: [PATCH 13/44] Refactor createHead method for player head creation --- .../net/arcaniax/gopaint/utils/Items.java | 56 ++++++++----------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/src/main/java/net/arcaniax/gopaint/utils/Items.java b/src/main/java/net/arcaniax/gopaint/utils/Items.java index b46aae41..80ec93fd 100644 --- a/src/main/java/net/arcaniax/gopaint/utils/Items.java +++ b/src/main/java/net/arcaniax/gopaint/utils/Items.java @@ -55,45 +55,33 @@ public ItemStack create(Material mat, short data, int amount, String name, Strin } public ItemStack createHead(String data, int amount, String name, String lore) { - ItemStack item; - if (XMaterial.isNewVersion()) { - item = XMaterial.PLAYER_HEAD.parseItem(); - } else { - item = new ItemStack(Material.getMaterial("SKULL_ITEM")); - item.setDurability((short) 3); - } + ItemStack item = new ItemStack(Material.PLAYER_HEAD, amount); + SkullMeta headMeta = (SkullMeta) item.getItemMeta(); + GameProfile profile = new GameProfile(id, "Arceon"); item.setAmount(amount); - ItemMeta meta = item.getItemMeta(); - if (!lore.isEmpty()) { - String[] loreListArray = lore.split("___"); - List loreList = new ArrayList<>(); - for (String s : loreListArray) { - loreList.add(s.replace("&", "§")); + profile.getProperties().put("textures", new Property("textures", String.valueOf(data))); + try { + PlayerProfile playerProfile = Bukkit.getServer().createPlayerProfile(UUID.randomUUID(), "goPaint"); + PlayerTextures texture = playerProfile.getTextures(); + String url = null; + byte[] decoded = Base64.getDecoder().decode(data); + try { + url = new String(decoded, StandardCharsets.UTF_8); + } catch (Exception ignored) {} + url = url.replace("{\"textures\":{\"SKIN\":{\"url\":\"", "").replace("\"}}}", ""); + texture.setSkin(new URL(url)); + headMeta.setOwnerProfile(playerProfile); + } catch (Exception ignored) { } - meta.setLore(loreList); + List loreList = new ArrayList<>(); + if (!lore.isEmpty()) { + loreList.add(lore); } + headMeta.setLore(loreList); if (!name.isEmpty()) { - meta.setDisplayName(name.replace("&", "§")); - } - item.setItemMeta(meta); - if (item.getItemMeta() instanceof SkullMeta) { - SkullMeta headMeta = (SkullMeta) item.getItemMeta(); - GameProfile profile = new GameProfile(UUID.randomUUID(), "goPaint"); - profile.getProperties().put("textures", new Property("textures", data)); - try { - Field profileField = headMeta.getClass().getDeclaredField("profile"); - profileField.setAccessible(true); - if (profileField.getType() == GameProfile.class) { - profileField.set(headMeta, profile); - } else { - Class resolvableProfileClass = Class.forName("net.minecraft.world.item.component.ResolvableProfile"); - profileField.set(headMeta, resolvableProfileClass.getConstructor(GameProfile.class).newInstance(profile)); - } - } catch (Exception ignored) { - } - item.setItemMeta(headMeta); + headMeta.setDisplayName(name); } + item.setItemMeta(headMeta); return item; } - } From f65a175f7c186f80d4812d6648cf0c7a6f4a7594 Mon Sep 17 00:00:00 2001 From: Arcaniax Date: Fri, 31 Oct 2025 15:31:49 +0100 Subject: [PATCH 14/44] Add PaperMC Maven repository to build.gradle.kts --- build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle.kts b/build.gradle.kts index 3b19c040..825c7f71 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,7 @@ repositories { name = "S01 Sonatype" url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") } + maven { url = uri("https://repo.papermc.io/repository/maven-public/") } } dependencies { From 024b64f23c2cbbb79edc2217b5a8e34c18f2f8b1 Mon Sep 17 00:00:00 2001 From: Arcaniax Date: Fri, 31 Oct 2025 16:19:11 +0100 Subject: [PATCH 15/44] Update createHead method for player head textures Refactor createHead method to use PlayerProfile and PlayerTextures for player head creation. --- src/main/java/net/arcaniax/gopaint/utils/Items.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/arcaniax/gopaint/utils/Items.java b/src/main/java/net/arcaniax/gopaint/utils/Items.java index 80ec93fd..e6543814 100644 --- a/src/main/java/net/arcaniax/gopaint/utils/Items.java +++ b/src/main/java/net/arcaniax/gopaint/utils/Items.java @@ -24,11 +24,19 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.SkullMeta; +import org.bukkit.profile.PlayerProfile; +import org.bukkit.profile.PlayerTextures; +import org.bukkit.Material; +import org.bukkit.Bukkit; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.UUID; public class Items { @@ -57,9 +65,6 @@ public ItemStack create(Material mat, short data, int amount, String name, Strin public ItemStack createHead(String data, int amount, String name, String lore) { ItemStack item = new ItemStack(Material.PLAYER_HEAD, amount); SkullMeta headMeta = (SkullMeta) item.getItemMeta(); - GameProfile profile = new GameProfile(id, "Arceon"); - item.setAmount(amount); - profile.getProperties().put("textures", new Property("textures", String.valueOf(data))); try { PlayerProfile playerProfile = Bukkit.getServer().createPlayerProfile(UUID.randomUUID(), "goPaint"); PlayerTextures texture = playerProfile.getTextures(); From 264fa1e6922e997e753351d767042cf7632e3f14 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 06:38:32 +0000 Subject: [PATCH 16/44] Update actions/upload-artifact action to v5 (#145) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b02c129..10b8ca0b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: - name: Clean Build run: ./gradlew clean build - name : Archive Artifacts - uses : actions/upload-artifact@v4 + uses : actions/upload-artifact@v5 with : name : goPaint-${{ env.CURRENT_VERSION }}-${{ env.SHA }} path : build/libs/goPaint-${{ env.CURRENT_VERSION }}-${{ env.SHA }}.jar From 6f70b890a8780b9c883789d775870f3d7b812b77 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 03:59:03 +0000 Subject: [PATCH 17/44] Update dependency dev.notmyfault.serverlib:ServerLib to v2.3.7 (#138) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 825c7f71..18435ad6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -108,7 +108,7 @@ tasks.named("shadowJar") { archiveClassifier.set(null as String?) dependencies { relocate("org.incendo.serverlib", "net.arcaniax.gopaint.serverlib") { - include(dependency("dev.notmyfault.serverlib:ServerLib:2.3.6")) + include(dependency("dev.notmyfault.serverlib:ServerLib:2.3.7")) } relocate("org.bstats", "net.arcaniax.gopaint.metrics") { include(dependency("org.bstats:bstats-base")) From 71cf146249059ad7a6780325a3c307fde63f44e5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 03:59:13 +0000 Subject: [PATCH 18/44] Update plugin org.ajoberstar.grgit to v5.3.3 (#139) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 18435ad6..ecd4ee45 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { id("com.diffplug.spotless") version "6.25.0" id("com.github.johnrengelman.shadow") version "8.1.1" - id("org.ajoberstar.grgit") version "5.3.0" + id("org.ajoberstar.grgit") version "5.3.3" id("net.minecrell.plugin-yml.bukkit") version "0.6.0" idea From e45fac8c0af54ee4707b9c412a807c83747c37a3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 08:37:16 +0000 Subject: [PATCH 19/44] Update Gradle to v8.14.3 (#136) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 9 ++++----- gradlew.bat | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch delta 34943 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

Gn4F+B~IivciU9^t0j-Mgao3 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 DDw4oP diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c82..d4081da4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f5feea6d..23d15a93 100755 --- a/gradlew +++ b/gradlew @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -115,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -206,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. @@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9b42019c..5eed7ee8 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell From 1d3793e78089284c7a65c7382b47bcd6efa132e3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 08:37:25 +0000 Subject: [PATCH 20/44] Update actions/checkout action to v5 (#141) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 10b8ca0b..4b0ca9da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@v4 - name : Grab SHA From b618f9a7369d372fbab14cc3b5449d31827ba8ce Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 18:08:43 +0000 Subject: [PATCH 21/44] Update actions/setup-java action to v5 (#142) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4b0ca9da..9eff0559 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: env : SHA : ${{ steps.short-sha.outputs.sha }} - name: Setup JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 From ef4c234c7eb994cd450d999a151851f79766d368 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 18:11:11 +0000 Subject: [PATCH 22/44] Update gradle/actions action to v5 (#143) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9eff0559..bee94e1b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,7 @@ jobs: - name: Checkout Repository uses: actions/checkout@v5 - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@v4 + uses: gradle/actions/wrapper-validation@v5 - name : Grab SHA uses : benjlevesque/short-sha@v3.0 id : short-sha From 7c7af93ab1d2a58cb86c92fd277e95c6c6f89320 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 23:04:42 +0000 Subject: [PATCH 23/44] Update plugin com.diffplug.spotless to v8 (#144) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index ecd4ee45..fc88a52b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { java `java-library` - id("com.diffplug.spotless") version "6.25.0" + id("com.diffplug.spotless") version "8.0.0" id("com.github.johnrengelman.shadow") version "8.1.1" id("org.ajoberstar.grgit") version "5.3.3" id("net.minecrell.plugin-yml.bukkit") version "0.6.0" From 8489c70fd379b8a7f844e1008806246d2ef6cb7f Mon Sep 17 00:00:00 2001 From: Arcaniax Date: Mon, 17 Nov 2025 16:22:58 +0100 Subject: [PATCH 24/44] Removal of ServerLib --- src/main/java/net/arcaniax/gopaint/GoPaintPlugin.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/net/arcaniax/gopaint/GoPaintPlugin.java b/src/main/java/net/arcaniax/gopaint/GoPaintPlugin.java index 952bd913..ca7c55c9 100644 --- a/src/main/java/net/arcaniax/gopaint/GoPaintPlugin.java +++ b/src/main/java/net/arcaniax/gopaint/GoPaintPlugin.java @@ -32,7 +32,6 @@ import org.bukkit.event.Listener; import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; -import org.incendo.serverlib.ServerLib; public class GoPaintPlugin extends JavaPlugin implements Listener { @@ -87,9 +86,6 @@ public void onEnable() { pm.registerEvents(cmdHandler, this); getCommand("gopaint").setExecutor(cmdHandler); DisabledBlocks.addBlocks(); - // Check if we are in a safe environment - ServerLib.checkUnsafeForks(); - ServerLib.isJavaSixteen(); PaperLib.suggestPaper(this); Metrics metrics = new Metrics(this, BSTATS_ID); From d36e1e58b69ce8dd310eccad2f7a67dc735188e5 Mon Sep 17 00:00:00 2001 From: Arcaniax Date: Mon, 17 Nov 2025 16:35:23 +0100 Subject: [PATCH 25/44] Fixed lore & displayname on Heads --- .../java/net/arcaniax/gopaint/utils/Items.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/arcaniax/gopaint/utils/Items.java b/src/main/java/net/arcaniax/gopaint/utils/Items.java index e6543814..03e2e6c4 100644 --- a/src/main/java/net/arcaniax/gopaint/utils/Items.java +++ b/src/main/java/net/arcaniax/gopaint/utils/Items.java @@ -20,6 +20,7 @@ import com.mojang.authlib.GameProfile; import com.mojang.authlib.properties.Property; +import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; @@ -78,13 +79,19 @@ public ItemStack createHead(String data, int amount, String name, String lore) { headMeta.setOwnerProfile(playerProfile); } catch (Exception ignored) { } - List loreList = new ArrayList<>(); if (!lore.isEmpty()) { - loreList.add(lore); + String[] loreListArray = lore.split("___"); + List loreList = new ArrayList(); + String[] arrayOfString1; + int j = (arrayOfString1 = loreListArray).length; + for (int i = 0; i < j; i++) { + String s = arrayOfString1[i]; + loreList.add(ChatColor.translateAlternateColorCodes('&', s)); + } + headMeta.setLore(loreList); } - headMeta.setLore(loreList); if (!name.isEmpty()) { - headMeta.setDisplayName(name); + headMeta.setDisplayName(ChatColor.translateAlternateColorCodes('&', name)); } item.setItemMeta(headMeta); return item; From 287ecb3a3d71627eeee5a709410ec49b0f1ce5af Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 08:34:31 +0000 Subject: [PATCH 26/44] Update plugin com.diffplug.spotless to v8.1.0 (#149) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index fc88a52b..dc5b0c4a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { java `java-library` - id("com.diffplug.spotless") version "8.0.0" + id("com.diffplug.spotless") version "8.1.0" id("com.github.johnrengelman.shadow") version "8.1.1" id("org.ajoberstar.grgit") version "5.3.3" id("net.minecrell.plugin-yml.bukkit") version "0.6.0" From 5cbfbc6ece22c52562d59244936d5ad3dbe8efd5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 08:34:41 +0000 Subject: [PATCH 27/44] Update actions/checkout action to v6 (#150) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bee94e1b..fbd6f48a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@v5 - name : Grab SHA From 7ab3652c61c4a0792a3e987b0423dc5bc879a2c8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 10:15:02 +0000 Subject: [PATCH 28/44] Update actions/upload-artifact action to v6 (#151) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fbd6f48a..b009cf48 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: - name: Clean Build run: ./gradlew clean build - name : Archive Artifacts - uses : actions/upload-artifact@v5 + uses : actions/upload-artifact@v6 with : name : goPaint-${{ env.CURRENT_VERSION }}-${{ env.SHA }} path : build/libs/goPaint-${{ env.CURRENT_VERSION }}-${{ env.SHA }}.jar From 50cf013238cf852e04b525d47c9924edda6f0b7d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 14:59:28 +0000 Subject: [PATCH 29/44] Update Gradle to v8.14.4 (#152) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da4..aaaabb3c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From eb374359fdde045b86f2e62628279683ea8a8af0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 15:00:31 +0000 Subject: [PATCH 30/44] Update plugin com.diffplug.spotless to v8.2.1 (#153) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index dc5b0c4a..eb9f85d7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { java `java-library` - id("com.diffplug.spotless") version "8.1.0" + id("com.diffplug.spotless") version "8.2.1" id("com.github.johnrengelman.shadow") version "8.1.1" id("org.ajoberstar.grgit") version "5.3.3" id("net.minecrell.plugin-yml.bukkit") version "0.6.0" From 68fd6fbdbe9a461b383dd34e8e801a213ca48439 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 07:37:11 +0000 Subject: [PATCH 31/44] Update dependency org.bstats:bstats-base to v3.2.1 (#154) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index eb9f85d7..1653f404 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -56,7 +56,7 @@ dependencies { compileOnlyApi("com.fastasyncworldedit:FastAsyncWorldEdit-Bukkit") implementation("dev.notmyfault.serverlib:ServerLib") implementation("org.bstats:bstats-bukkit:3.1.0") - implementation("org.bstats:bstats-base:3.1.0") + implementation("org.bstats:bstats-base:3.2.1") implementation("io.papermc:paperlib") } From f7cf94efa9a919d8ac2dc9a0abf2c1e1598d02bf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:50:42 +0000 Subject: [PATCH 32/44] Update dependency org.bstats:bstats-bukkit to v3.2.1 (#155) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 1653f404..aab3e406 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,7 +55,7 @@ dependencies { compileOnly("com.mojang:authlib:1.5.25") compileOnlyApi("com.fastasyncworldedit:FastAsyncWorldEdit-Bukkit") implementation("dev.notmyfault.serverlib:ServerLib") - implementation("org.bstats:bstats-bukkit:3.1.0") + implementation("org.bstats:bstats-bukkit:3.2.1") implementation("org.bstats:bstats-base:3.2.1") implementation("io.papermc:paperlib") } From 1e5c2823108db9c1caf2dd37708543da37b1b028 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:50:53 +0000 Subject: [PATCH 33/44] Update actions/upload-artifact action to v7 (#156) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b009cf48..f1186122 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: - name: Clean Build run: ./gradlew clean build - name : Archive Artifacts - uses : actions/upload-artifact@v6 + uses : actions/upload-artifact@v7 with : name : goPaint-${{ env.CURRENT_VERSION }}-${{ env.SHA }} path : build/libs/goPaint-${{ env.CURRENT_VERSION }}-${{ env.SHA }}.jar From 5511bd22d40b22a2b7faa85301afb7a09511a620 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:54:13 +0000 Subject: [PATCH 34/44] Update plugin com.diffplug.spotless to v8.4.0 (#157) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index aab3e406..a9860c08 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { java `java-library` - id("com.diffplug.spotless") version "8.2.1" + id("com.diffplug.spotless") version "8.4.0" id("com.github.johnrengelman.shadow") version "8.1.1" id("org.ajoberstar.grgit") version "5.3.3" id("net.minecrell.plugin-yml.bukkit") version "0.6.0" From 9330f07250813f5f6ed3b663cbba71568a644065 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:54:22 +0000 Subject: [PATCH 35/44] Update gradle/actions action to v6 (#158) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f1186122..7610baeb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,7 @@ jobs: - name: Checkout Repository uses: actions/checkout@v6 - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@v5 + uses: gradle/actions/wrapper-validation@v6 - name : Grab SHA uses : benjlevesque/short-sha@v3.0 id : short-sha From d722c9f2c6208521e9d6bebb12ddba945f6e34e3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:33:02 +0000 Subject: [PATCH 36/44] Update Gradle to v8.14.5 (#160) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index aaaabb3c..4f5eb9dc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 5e387df40ca17e611e52de33884ab3edb6a49d9c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:33:14 +0000 Subject: [PATCH 37/44] Update plugin com.diffplug.spotless to v8.6.0 (#161) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index a9860c08..96bb93ab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { java `java-library` - id("com.diffplug.spotless") version "8.4.0" + id("com.diffplug.spotless") version "8.6.0" id("com.github.johnrengelman.shadow") version "8.1.1" id("org.ajoberstar.grgit") version "5.3.3" id("net.minecrell.plugin-yml.bukkit") version "0.6.0" From f1b3d7da495f25fe2a78d5a9b8127215dff9f928 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:48:11 +0000 Subject: [PATCH 38/44] Update actions/checkout action to v7 (#163) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7610baeb..b1f141b7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@v6 - name : Grab SHA From 2ab5e7c1f419ec0ac7b5ae7a1a4cb695f3321486 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:56:22 +0000 Subject: [PATCH 39/44] Update benjlevesque/short-sha action to v4 (#164) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b1f141b7..2a665744 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ jobs: - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@v6 - name : Grab SHA - uses : benjlevesque/short-sha@v3.0 + uses : benjlevesque/short-sha@v4.0 id : short-sha with : length : 7 From fc0542ac394d01a11ce60d1c5a0586b6c7b55829 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:56:28 +0000 Subject: [PATCH 40/44] Update plugin com.diffplug.spotless to v8.8.0 (#162) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 96bb93ab..7d363862 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { java `java-library` - id("com.diffplug.spotless") version "8.6.0" + id("com.diffplug.spotless") version "8.8.0" id("com.github.johnrengelman.shadow") version "8.1.1" id("org.ajoberstar.grgit") version "5.3.3" id("net.minecrell.plugin-yml.bukkit") version "0.6.0" From 67802e632bfe4c1332561e96fb2633361da05403 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:14:35 +0000 Subject: [PATCH 41/44] Update plugin com.diffplug.spotless to v8.9.0 (#165) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 7d363862..ec45ac1e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { java `java-library` - id("com.diffplug.spotless") version "8.8.0" + id("com.diffplug.spotless") version "8.9.0" id("com.github.johnrengelman.shadow") version "8.1.1" id("org.ajoberstar.grgit") version "5.3.3" id("net.minecrell.plugin-yml.bukkit") version "0.6.0" From 97f178928710390a1ef1764ba0ccf3861da76fd2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:07:00 +0000 Subject: [PATCH 42/44] Update plugin com.diffplug.spotless to v8.10.1 (#166) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index ec45ac1e..d6183712 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { java `java-library` - id("com.diffplug.spotless") version "8.9.0" + id("com.diffplug.spotless") version "8.10.1" id("com.github.johnrengelman.shadow") version "8.1.1" id("org.ajoberstar.grgit") version "5.3.3" id("net.minecrell.plugin-yml.bukkit") version "0.6.0" From 7d033cb78ba470d76f7119ec8e500aefaeafb6e0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:07:15 +0000 Subject: [PATCH 43/44] Update actions/setup-java action to v6 (#167) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2a665744..6227b18d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: env : SHA : ${{ steps.short-sha.outputs.sha }} - name: Setup JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 17 From 93bc6ad44868390413f7f33429c30dec137d844a Mon Sep 17 00:00:00 2001 From: OneLiteFeather <77929075+OneLiteFeather@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:38:14 +0200 Subject: [PATCH 44/44] feat!: modernise BetterGoPaint for Minecraft 26 and AntiRedstoneClock quality (#34) * feat!: support Minecraft 26.1 and 26.2 on Java 25 Compile against Paper API 26.2 and FastAsyncWorldEdit 2.16.1 with Gradle 9.8 and Java 25. Versions move into one catalog, shadow/plugin-yml/run-paper/hangar/spotless use their current coordinates, and build-logic, ServerLib, paperlib and EldoNexus publishing are dropped. Translations use MiniMessageTranslationStore because Adventure removed TranslationRegistry; extra language files in lang/ now fall back to the bundled English bundle. run- tasks start Paper with FastAsyncWorldEdit installed. BREAKING CHANGE: requires Paper 26.1.1 or newer and Java 25. Servers on 1.21 stay on 1.1.x. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * test: add unit tests for brushes, curves and translations JUnit 6, AssertJ and Mockito with JaCoCo reports. Covers brush cycling and lookup, the Paint Brush curve and translation key completeness. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * ci: adopt release please, commit linting and security scans Mirror the AntiRedstoneClock-Remastered pipeline: pr-lint, multi-OS PR builds on Java 25, Release Please on main with a Trivy gate before Hangar/Modrinth publishing and an SBOM upload, weekly Trivy scan, shared Renovate presets and new issue forms. Snapshot publishing on every push and release-drafter are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * docs: restructure documentation along diataxis Tutorial, how-to guides, reference pages for brushes, commands, permissions, configuration and supported versions, background pages and developer guides, published through GitBook. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * docs: add openspec with the modernisation roadmap OpenSpec changes for the four delivered parts (platform upgrade, release pipeline, tests, documentation) and the planned follow-ups: cloud commands, brush menu hardening, non-blocking update check and dependency injection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * fix: check for updates without blocking server start The first update check ran on the main thread during onEnable. It now runs from an async timer with connect and request timeouts, logs failures at debug level, tells the console once per new version and players only on join. generic.check-for-updates turns it off. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * feat!: rebuild /gopaint on cloud with brigadier and translated messages One command tree /gopaint (aliases /gp, /bgp) through cloud's PaperCommandManager with per-subcommand permissions, a generated help, range-checked and tab-completed sizes and messages from the translation bundle. The Bukkit Command subclass and the separate reload command are gone. BREAKING CHANGE: /gopaint reload needs bettergopaint.command.admin.reload (granted by the bettergopaint.admin bundle); every subcommand needs its own bettergopaint.command.* node, which defaults to true. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * fix: identify brush menus by holder and store exported brushes in item data The menu and the brush picker are recognised by their InventoryHolder instead of the title, so foreign inventories with the same title are no longer hijacked. Menu text comes from the translation bundle, rendered for the player's locale. Exported brushes are stored as versioned data under bettergopaint:brush; renamed or edited items keep working and 1.x items are converted on first use without ever throwing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * fix: honour config.yml again and wire the plugin with guice Settings no longer extend FAWE's Config, which on Paper 26 could not set the plugin's fields and ignored every value in config.yml. They are now immutable records behind SettingsHolder, read by a small YAML loader that writes every key back with its comment, replaces invalid values with their default and swaps the snapshot atomically on reload. Listeners, commands and services receive narrow dependencies through Guice modules instead of the plugin instance. Guice, jakarta.inject and aopalliance are shaded and relocated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * docs: archive the implemented openspec changes All eight changes are implemented. Their delta specs are merged into openspec/specs/; the steps that need a game client, GitHub repository settings or GitBook access are listed as manual follow-ups in the archived task lists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * docs: add the server admin guide A setup tutorial for admins, how-to guides for upgrading from 1.x, limiting builders, applying config changes, turning off update notices and reporting bugs, reference pages for console messages and files, and background on how settings are loaded and why 2.x needs Paper 26. ReferenceDocsTest fails the build when config keys, defaults, permissions, commands, supported versions or the navigation drift from the code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * docs: plan native worldedit brushes and folia readiness Two OpenSpec changes: brushes compute on WorldEdit extents instead of the Bukkit world, which also makes strokes thread-safe, and scheduling plus shared state become Folia-ready while the Folia flag stays off until FastAsyncWorldEdit supports it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * refactor: add stroke context, mask and in-memory extent for native brushes Add an immutable StrokeContext that is captured on the player's thread, a factory that converts the brush settings to WorldEdit types without asking the world for anything, a WorldEdit mask for the BetterGoPaint mask and a runner that executes a stroke in the player's WorldEdit session. Tests get a map-backed MemoryExtent and mock block types, because the WorldEdit block registry cannot initialise without a server. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * refactor: read and write brush strokes through worldedit extents only Brushes now compute their blocks from a StrokeContext and an Extent. The geometry helpers (Sphere, Height, Surface, ConnectedBlocks) and the Bezier curve use BlockVector3 and Vector3, so no Bukkit world, block or location call remains in objects/ and utils/. InteractListener captures the context on the player's thread and queues a WorldEdit Brush that runs in the player's edit session with its global mask and one history entry. Brushes read all positions before they write, which keeps the result the same as when the Bukkit world was read. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * docs: describe the native worldedit stroke and record design deviations Explain the stroke context, the player's WorldEdit session and //gmask in the stroke explanation, document how brushes are tested without a server and note where the implementation differs from the design decisions. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * fix: schedule update check on paper's async scheduler The legacy BukkitScheduler is not available on Folia. Run the update check with Bukkit.getAsyncScheduler().runAtFixedRate and cancel the returned ScheduledTask on shutdown. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * refactor: make per-player brush state thread-safe PlayerBrushManager keeps its per-player brushes in a ConcurrentHashMap and takes a brush factory so the concurrency behaviour can be tested without a server. The Paint Brush stroke points move from a static map into the brush instance and are dropped with the player's brush state on quit. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * build: drive foliaSupported from a gradle property paper-plugin.yml declares folia-supported only when -PfoliaSupported=true is passed. Without it the flag stays false, so release builds cannot declare Folia support by accident. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * build: add run-folia tasks with a configurable fawe url run-folia- starts a local Folia test server with run-paper's Folia downloads. It installs FastAsyncWorldEdit from -PfoliaFaweUrl. Without that property the task fails when it runs, with a message that names the property, so normal builds are unaffected. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * docs: state folia status and how to test on folia Scope and non-goals now says BetterGoPaint is prepared for Folia but does not declare support until a stable FastAsyncWorldEdit release supports it. Supported versions links to that page, and the testing guide describes run-folia- for local testing only. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * build: refuse run-folia without the folia flag Without -PfoliaSupported=true the test server starts but Folia skips BetterGoPaint, which looks like a broken setup. The task now fails early and names both required properties. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp * docs: archive native worldedit brushes and folia readiness Both changes are implemented; their specs move to openspec/specs/. In-game checks with a client and a Folia test with a FAWE fork remain listed as manual follow-ups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLYZtULzHeMRpNygcFFWgp --------- Co-authored-by: Claude --- .claude/commands/opsx/apply.md | 202 ++++++++++ .claude/commands/opsx/archive.md | 264 +++++++++++++ .claude/commands/opsx/explore.md | 239 ++++++++++++ .claude/commands/opsx/propose.md | 170 +++++++++ .claude/commands/opsx/sync.md | 286 ++++++++++++++ .claude/commands/opsx/update.md | 105 ++++++ .claude/skills/openspec-apply-change/SKILL.md | 206 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 221 +++++++++++ .claude/skills/openspec-explore/SKILL.md | 354 ++++++++++++++++++ .claude/skills/openspec-propose/SKILL.md | 174 +++++++++ .claude/skills/openspec-sync-specs/SKILL.md | 290 ++++++++++++++ .../skills/openspec-update-change/SKILL.md | 109 ++++++ .gitbook.yaml | 8 + .github/ISSUE_TEMPLATE/bug-report.yml | 98 +++++ .github/ISSUE_TEMPLATE/bug_report.yml | 107 ------ .github/ISSUE_TEMPLATE/feature-request.yml | 50 +++ .github/ISSUE_TEMPLATE/feature_request.yml | 30 -- .github/PULL_REQUEST_TEMPLATE.md | 16 - .github/pull_request_template.md | 53 +++ .github/release-drafter.yml | 5 - .github/renovate.json | 11 - .github/stale.yml | 18 - .github/workflows/build-pr.yml | 94 ++++- .github/workflows/close_invalid_prs.yml | 4 +- .github/workflows/pr-lint.yml | 63 ++++ .github/workflows/publish.yml | 27 -- .github/workflows/release-drafter.yml | 22 -- .github/workflows/release-please.yml | 216 +++++++++++ .github/workflows/security.yml | 36 ++ .gitignore | 1 - .release-please-manifest.json | 3 + CONTRIBUTING.md | 62 +++ README.md | 59 ++- build-logic/build.gradle.kts | 7 - build-logic/src/main/kotlin/extensions.kt | 43 --- build.gradle.kts | 300 +++++++++------ commitlint.config.mjs | 18 + docs/SUMMARY.md | 42 +++ docs/development/dependency-injection.md | 34 ++ docs/development/documentation.md | 28 ++ docs/development/testing.md | 62 +++ .../how-a-brush-stroke-is-applied.md | 34 ++ docs/explanation/how-settings-are-loaded.md | 28 ++ docs/explanation/scope-and-non-goals.md | 18 + .../why-bettergopaint-2-needs-paper-26.md | 13 + docs/how-to/apply-configuration-changes.md | 10 + docs/how-to/change-the-brush-item.md | 13 + docs/how-to/change-the-plugin-language.md | 19 + docs/how-to/disable-painting-in-a-world.md | 14 + docs/how-to/limit-what-builders-can-do.md | 38 ++ docs/how-to/report-a-bug.md | 16 + docs/how-to/share-a-brush-as-an-item.md | 19 + docs/how-to/turn-off-update-notifications.md | 14 + docs/how-to/upgrade-from-bettergopaint-1.md | 26 ++ docs/index.md | 33 ++ docs/reference/brushes.md | 22 ++ docs/reference/commands.md | 15 + docs/reference/configuration.md | 50 +++ docs/reference/console-messages.md | 13 + docs/reference/files-and-folders.md | 11 + docs/reference/permissions.md | 19 + docs/reference/supported-versions.md | 14 + docs/tutorial/paint-your-first-terrain.md | 54 +++ ...et-up-bettergopaint-for-your-build-team.md | 81 ++++ gradle.properties | 5 +- gradle/wrapper/gradle-wrapper.jar | Bin 43453 -> 47623 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 19 +- gradlew.bat | 88 +++-- .../changes/archive/.gitkeep | 0 .../.openspec.yaml | 2 + .../2026-10-09-add-test-foundation/design.md | 30 ++ .../proposal.md | 33 ++ .../specs/build-verification/spec.md | 37 ++ .../2026-10-09-add-test-foundation/tasks.md | 18 + .../.openspec.yaml | 2 + .../design.md | 44 +++ .../proposal.md | 47 +++ .../specs/release-pipeline/spec.md | 59 +++ .../tasks.md | 33 ++ .../.openspec.yaml | 2 + .../design.md | 34 ++ .../proposal.md | 42 +++ .../specs/server-threading/spec.md | 35 ++ .../tasks.md | 21 ++ .../.openspec.yaml | 2 + .../2026-10-09-harden-brush-menu/design.md | 28 ++ .../2026-10-09-harden-brush-menu/proposal.md | 36 ++ .../specs/brush-menu/spec.md | 35 ++ .../specs/exported-brushes/spec.md | 32 ++ .../2026-10-09-harden-brush-menu/tasks.md | 17 + .../.openspec.yaml | 3 + .../design.md | 26 ++ .../proposal.md | 37 ++ .../tasks.md | 16 + .../.openspec.yaml | 2 + .../design.md | 24 ++ .../proposal.md | 35 ++ .../specs/commands/spec.md | 42 +++ .../tasks.md | 16 + .../.openspec.yaml | 2 + .../design.md | 97 +++++ .../proposal.md | 46 +++ .../specs/brush-painting/spec.md | 46 +++ .../tasks.md | 25 ++ .../.openspec.yaml | 2 + .../proposal.md | 31 ++ .../specs/update-notification/spec.md | 37 ++ .../tasks.md | 12 + .../.openspec.yaml | 2 + .../proposal.md | 33 ++ .../specs/user-documentation/spec.md | 31 ++ .../tasks.md | 24 ++ .../.openspec.yaml | 2 + .../design.md | 60 +++ .../proposal.md | 48 +++ .../specs/platform-compatibility/spec.md | 56 +++ .../tasks.md | 35 ++ openspec/config.yaml | 50 +++ openspec/specs/.gitkeep | 0 openspec/specs/brush-menu/spec.md | 34 ++ openspec/specs/brush-painting/spec.md | 45 +++ openspec/specs/build-verification/spec.md | 36 ++ openspec/specs/commands/spec.md | 41 ++ openspec/specs/exported-brushes/spec.md | 31 ++ openspec/specs/platform-compatibility/spec.md | 55 +++ openspec/specs/release-pipeline/spec.md | 58 +++ openspec/specs/server-threading/spec.md | 34 ++ openspec/specs/update-notification/spec.md | 36 ++ openspec/specs/user-documentation/spec.md | 30 ++ release-please-config.json | 30 ++ renovate.json | 10 +- settings.gradle.kts | 72 ++-- .../bettergopaint/BetterGoPaint.java | 198 +++------- .../brush/ExportedBrushCodec.java | 193 ++++++++++ .../bettergopaint/brush/ExportedBrushes.java | 83 ++++ .../brush/ExportedPlayerBrush.java | 61 +-- .../bettergopaint/brush/PlayerBrush.java | 140 ++++--- .../brush/PlayerBrushManager.java | 71 ++-- .../bettergopaint/brush/SingleTypeMask.java | 85 +++++ .../bettergopaint/brush/StrokeContext.java | 128 +++++++ .../brush/StrokeContextFactory.java | 115 ++++++ .../bettergopaint/brush/StrokeRunner.java | 60 +++ .../bettergopaint/command/BrushSizes.java | 51 +++ .../bettergopaint/command/GoPaintCommand.java | 185 +++++---- .../BrushModule.java} | 27 +- .../injection/CommandModule.java | 50 +++ .../injection/ListenerModule.java | 40 ++ .../injection/PlatformModule.java | 44 +++ .../injection/ServiceModule.java | 37 ++ .../injection/SettingsModule.java | 46 +++ .../listeners/ConnectListener.java | 33 +- .../listeners/InteractListener.java | 85 +++-- .../listeners/InventoryListener.java | 43 +-- .../bettergopaint/menu/BrushMenuHolder.java | 45 +++ .../bettergopaint/menu/BrushPickerHolder.java | 44 +++ .../objects/brush/AngleBrush.java | 30 +- .../bettergopaint/objects/brush/Brush.java | 184 +++++---- .../objects/brush/BucketBrush.java | 26 +- .../objects/brush/DiscBrush.java | 24 +- .../objects/brush/FractureBrush.java | 46 +-- .../objects/brush/GradientBrush.java | 58 +-- .../objects/brush/OverlayBrush.java | 32 +- .../objects/brush/PaintBrush.java | 145 ++++--- .../objects/brush/SphereBrush.java | 24 +- .../objects/brush/SplatterBrush.java | 43 +-- .../objects/brush/SprayBrush.java | 26 +- .../objects/brush/UnderlayBrush.java | 32 +- .../bettergopaint/objects/other/Settings.java | 135 ------- .../objects/other/SurfaceMode.java | 11 +- .../service/HangarReleaseSource.java | 73 ++++ .../bettergopaint/service/MetricsService.java | 61 +++ .../service/TranslationService.java | 79 ++++ .../bettergopaint/service/UpdateService.java | 162 +++++--- .../bettergopaint/settings/Settings.java | 89 +++++ .../settings/SettingsHolder.java | 72 ++++ .../settings/SettingsLoader.java | 170 +++++++++ .../PluginTranslationRegistry.java | 138 ------- .../bettergopaint/utils/Blocks.java | 88 +++++ .../bettergopaint/utils/ConnectedBlocks.java | 67 ++-- .../bettergopaint/utils/Constants.java | 10 +- .../bettergopaint/utils/GUI.java | 338 ++++++++--------- .../bettergopaint/utils/Height.java | 80 ++-- .../bettergopaint/utils/Items.java | 31 +- .../bettergopaint/utils/Messages.java | 38 ++ .../bettergopaint/utils/Sphere.java | 80 ++-- .../bettergopaint/utils/Surface.java | 80 ++-- .../utils/curve/BezierSpline.java | 172 ++++----- .../utils/curve/BezierSplineSegment.java | 99 +++-- src/main/resources/bettergopaint.properties | 61 +++ .../brush/ExportedBrushCodecTest.java | 112 ++++++ .../brush/PlayerBrushManagerTest.java | 146 ++++++++ .../brush/StrokeContextFactoryTest.java | 187 +++++++++ .../bettergopaint/command/BrushSizesTest.java | 52 +++ .../bettergopaint/docs/ReferenceDocsTest.java | 128 +++++++ .../injection/DIValidationTest.java | 120 ++++++ .../listeners/ConnectListenerTest.java | 71 ++++ .../listeners/InteractListenerTest.java | 116 ++++++ .../listeners/InventoryListenerTest.java | 75 ++++ .../brush/NoBukkitWorldAccessTest.java | 74 ++++ .../objects/brush/PaintBrushTest.java | 68 ++++ .../objects/brush/ShapeBrushesTest.java | 191 ++++++++++ .../objects/brush/StrokeRulesTest.java | 200 ++++++++++ .../objects/brush/TerrainBrushesTest.java | 222 +++++++++++ .../service/UpdateServiceTest.java | 91 +++++ .../settings/SettingsHolderTest.java | 110 ++++++ .../support/BrushAssertions.java | 61 +++ .../bettergopaint/support/MemoryExtent.java | 182 +++++++++ .../support/MemoryExtentTest.java | 75 ++++ .../bettergopaint/support/StrokeBuilder.java | 161 ++++++++ .../bettergopaint/support/Terrain.java | 85 +++++ .../bettergopaint/support/TestBlocks.java | 87 +++++ .../translations/TranslationBundleTest.java | 110 ++++++ .../utils/ConnectedBlocksTest.java | 81 ++++ .../bettergopaint/utils/HeightTest.java | 98 +++++ .../bettergopaint/utils/SphereTest.java | 127 +++++++ .../bettergopaint/utils/SurfaceTest.java | 72 ++++ .../utils/curve/BezierSplineTest.java | 96 +++++ 218 files changed, 12505 insertions(+), 2129 deletions(-) create mode 100644 .claude/commands/opsx/apply.md create mode 100644 .claude/commands/opsx/archive.md create mode 100644 .claude/commands/opsx/explore.md create mode 100644 .claude/commands/opsx/propose.md create mode 100644 .claude/commands/opsx/sync.md create mode 100644 .claude/commands/opsx/update.md create mode 100644 .claude/skills/openspec-apply-change/SKILL.md create mode 100644 .claude/skills/openspec-archive-change/SKILL.md create mode 100644 .claude/skills/openspec-explore/SKILL.md create mode 100644 .claude/skills/openspec-propose/SKILL.md create mode 100644 .claude/skills/openspec-sync-specs/SKILL.md create mode 100644 .claude/skills/openspec-update-change/SKILL.md create mode 100644 .gitbook.yaml create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/pull_request_template.md delete mode 100644 .github/release-drafter.yml delete mode 100644 .github/renovate.json delete mode 100644 .github/stale.yml create mode 100644 .github/workflows/pr-lint.yml delete mode 100644 .github/workflows/publish.yml delete mode 100644 .github/workflows/release-drafter.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/security.yml create mode 100644 .release-please-manifest.json create mode 100644 CONTRIBUTING.md delete mode 100644 build-logic/build.gradle.kts delete mode 100644 build-logic/src/main/kotlin/extensions.kt create mode 100644 commitlint.config.mjs create mode 100644 docs/SUMMARY.md create mode 100644 docs/development/dependency-injection.md create mode 100644 docs/development/documentation.md create mode 100644 docs/development/testing.md create mode 100644 docs/explanation/how-a-brush-stroke-is-applied.md create mode 100644 docs/explanation/how-settings-are-loaded.md create mode 100644 docs/explanation/scope-and-non-goals.md create mode 100644 docs/explanation/why-bettergopaint-2-needs-paper-26.md create mode 100644 docs/how-to/apply-configuration-changes.md create mode 100644 docs/how-to/change-the-brush-item.md create mode 100644 docs/how-to/change-the-plugin-language.md create mode 100644 docs/how-to/disable-painting-in-a-world.md create mode 100644 docs/how-to/limit-what-builders-can-do.md create mode 100644 docs/how-to/report-a-bug.md create mode 100644 docs/how-to/share-a-brush-as-an-item.md create mode 100644 docs/how-to/turn-off-update-notifications.md create mode 100644 docs/how-to/upgrade-from-bettergopaint-1.md create mode 100644 docs/index.md create mode 100644 docs/reference/brushes.md create mode 100644 docs/reference/commands.md create mode 100644 docs/reference/configuration.md create mode 100644 docs/reference/console-messages.md create mode 100644 docs/reference/files-and-folders.md create mode 100644 docs/reference/permissions.md create mode 100644 docs/reference/supported-versions.md create mode 100644 docs/tutorial/paint-your-first-terrain.md create mode 100644 docs/tutorial/set-up-bettergopaint-for-your-build-team.md rename build-logic/src/main/kotlin/olf.build-logic.gradle.kts => openspec/changes/archive/.gitkeep (100%) create mode 100644 openspec/changes/archive/2026-10-09-add-test-foundation/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-09-add-test-foundation/design.md create mode 100644 openspec/changes/archive/2026-10-09-add-test-foundation/proposal.md create mode 100644 openspec/changes/archive/2026-10-09-add-test-foundation/specs/build-verification/spec.md create mode 100644 openspec/changes/archive/2026-10-09-add-test-foundation/tasks.md create mode 100644 openspec/changes/archive/2026-10-09-adopt-release-pipeline/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-09-adopt-release-pipeline/design.md create mode 100644 openspec/changes/archive/2026-10-09-adopt-release-pipeline/proposal.md create mode 100644 openspec/changes/archive/2026-10-09-adopt-release-pipeline/specs/release-pipeline/spec.md create mode 100644 openspec/changes/archive/2026-10-09-adopt-release-pipeline/tasks.md create mode 100644 openspec/changes/archive/2026-10-09-folia-ready-scheduling/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-09-folia-ready-scheduling/design.md create mode 100644 openspec/changes/archive/2026-10-09-folia-ready-scheduling/proposal.md create mode 100644 openspec/changes/archive/2026-10-09-folia-ready-scheduling/specs/server-threading/spec.md create mode 100644 openspec/changes/archive/2026-10-09-folia-ready-scheduling/tasks.md create mode 100644 openspec/changes/archive/2026-10-09-harden-brush-menu/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-09-harden-brush-menu/design.md create mode 100644 openspec/changes/archive/2026-10-09-harden-brush-menu/proposal.md create mode 100644 openspec/changes/archive/2026-10-09-harden-brush-menu/specs/brush-menu/spec.md create mode 100644 openspec/changes/archive/2026-10-09-harden-brush-menu/specs/exported-brushes/spec.md create mode 100644 openspec/changes/archive/2026-10-09-harden-brush-menu/tasks.md create mode 100644 openspec/changes/archive/2026-10-09-introduce-dependency-injection/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-09-introduce-dependency-injection/design.md create mode 100644 openspec/changes/archive/2026-10-09-introduce-dependency-injection/proposal.md create mode 100644 openspec/changes/archive/2026-10-09-introduce-dependency-injection/tasks.md create mode 100644 openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/design.md create mode 100644 openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/proposal.md create mode 100644 openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/specs/commands/spec.md create mode 100644 openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/tasks.md create mode 100644 openspec/changes/archive/2026-10-09-native-worldedit-brushes/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-09-native-worldedit-brushes/design.md create mode 100644 openspec/changes/archive/2026-10-09-native-worldedit-brushes/proposal.md create mode 100644 openspec/changes/archive/2026-10-09-native-worldedit-brushes/specs/brush-painting/spec.md create mode 100644 openspec/changes/archive/2026-10-09-native-worldedit-brushes/tasks.md create mode 100644 openspec/changes/archive/2026-10-09-non-blocking-update-check/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-09-non-blocking-update-check/proposal.md create mode 100644 openspec/changes/archive/2026-10-09-non-blocking-update-check/specs/update-notification/spec.md create mode 100644 openspec/changes/archive/2026-10-09-non-blocking-update-check/tasks.md create mode 100644 openspec/changes/archive/2026-10-09-restructure-documentation/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-09-restructure-documentation/proposal.md create mode 100644 openspec/changes/archive/2026-10-09-restructure-documentation/specs/user-documentation/spec.md create mode 100644 openspec/changes/archive/2026-10-09-restructure-documentation/tasks.md create mode 100644 openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/design.md create mode 100644 openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/proposal.md create mode 100644 openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/specs/platform-compatibility/spec.md create mode 100644 openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/tasks.md create mode 100644 openspec/config.yaml create mode 100644 openspec/specs/.gitkeep create mode 100644 openspec/specs/brush-menu/spec.md create mode 100644 openspec/specs/brush-painting/spec.md create mode 100644 openspec/specs/build-verification/spec.md create mode 100644 openspec/specs/commands/spec.md create mode 100644 openspec/specs/exported-brushes/spec.md create mode 100644 openspec/specs/platform-compatibility/spec.md create mode 100644 openspec/specs/release-pipeline/spec.md create mode 100644 openspec/specs/server-threading/spec.md create mode 100644 openspec/specs/update-notification/spec.md create mode 100644 openspec/specs/user-documentation/spec.md create mode 100644 release-please-config.json create mode 100644 src/main/java/net/onelitefeather/bettergopaint/brush/ExportedBrushCodec.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/brush/ExportedBrushes.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/brush/SingleTypeMask.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContext.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactory.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/brush/StrokeRunner.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/command/BrushSizes.java rename src/main/java/net/onelitefeather/bettergopaint/{command/ReloadCommand.java => injection/BrushModule.java} (56%) create mode 100644 src/main/java/net/onelitefeather/bettergopaint/injection/CommandModule.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/injection/ListenerModule.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/injection/PlatformModule.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/injection/ServiceModule.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/injection/SettingsModule.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/menu/BrushMenuHolder.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/menu/BrushPickerHolder.java delete mode 100644 src/main/java/net/onelitefeather/bettergopaint/objects/other/Settings.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/service/HangarReleaseSource.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/service/MetricsService.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/service/TranslationService.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/settings/Settings.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/settings/SettingsHolder.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/settings/SettingsLoader.java delete mode 100644 src/main/java/net/onelitefeather/bettergopaint/translations/PluginTranslationRegistry.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/utils/Blocks.java create mode 100644 src/main/java/net/onelitefeather/bettergopaint/utils/Messages.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/brush/ExportedBrushCodecTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactoryTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/command/BrushSizesTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/docs/ReferenceDocsTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/injection/DIValidationTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/listeners/ConnectListenerTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/listeners/InteractListenerTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/listeners/InventoryListenerTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/objects/brush/NoBukkitWorldAccessTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrushTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/objects/brush/ShapeBrushesTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/objects/brush/StrokeRulesTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/objects/brush/TerrainBrushesTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/service/UpdateServiceTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/settings/SettingsHolderTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/support/BrushAssertions.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtent.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtentTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/support/StrokeBuilder.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/support/Terrain.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/support/TestBlocks.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/translations/TranslationBundleTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocksTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/utils/HeightTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/utils/SphereTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/utils/SurfaceTest.java create mode 100644 src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md new file mode 100644 index 00000000..2b718c94 --- /dev/null +++ b/.claude/commands/opsx/apply.md @@ -0,0 +1,202 @@ +--- +name: "OPSX: Apply" +description: "Implement tasks from an OpenSpec change (Experimental)" +allowed-tools: Bash(openspec:*) +category: "Workflow" +tags: ["workflow", "artifacts", "experimental"] +--- + +Implement tasks from an OpenSpec change. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - Progress (total, complete, remaining) + - Task list with status, source path, and source line + - Dynamic instruction based on current state + - Optional `context`: current required project instruction input from the selected root + - Optional `operationGuidance`: current advisory guidance for apply + - `missingArtifacts` (when present): required artifact ids with no output + + **Handle states:** + - If `state: "blocked"`: show the message and pause implementation. + - If `missingArtifacts` is non-empty: suggest completing the missing artifacts. Run `openspec status --change "" --json`, select the next `ready` artifact (not `skipped` or `blocked`), and use `openspec instructions "" --change "" --json` for its rules and template. Keep the selected `--store ` on both commands. + - Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked. + - If `state: "all_done"`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving + - Otherwise: proceed to implementation + + Treat `context` as a required prompt-level input. Read and consider it, and + apply relevant project facts, conventions, and constraints while implementing. + Treat `operationGuidance` as optional additive advice. Read and consider every + entry, and follow entries that are applicable and compatible with the built-in + workflow. + + Keep both fields separate from CLI-returned state, missing artifacts, tasks, + progress, `contextFiles`, and the built-in `instruction`. They are not + evidence of task completion, do not replace the built-in instruction, and do + not permit bypassing a blocked state. If context conflicts with the built-in + instruction, an explicit user choice, or a CLI-controlled value, report the + conflict and preserve the controlling value. If guidance is inapplicable or + conflicts with those controlling inputs, do not follow it and explain why. + These are prompt-level behavior contracts, not enforceable checks. + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + + Do not copy `context` or `operationGuidance` verbatim into implementation + files or planning artifacts unless the user separately asks for that content. + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Before editing, confirm the checkbox at the returned `sourcePath` and `line` still matches the task description; if it does not, rerun the apply instructions and use the refreshed location + - Mark the task complete at its returned `sourcePath` and `line`: `- [ ]` → `- [x]` + - Rerun the apply instructions and confirm that task is now done and progress changed + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - A task needs work beyond what the spec and tasks describe, or you are tempted to drop, narrow, defer, or accept exceptions to specified behavior to make it fit → surface the added scope and ask; do not absorb it silently + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tracked tasks are complete. Review or verify the change as appropriate +before archiving. You can archive this change with `/opsx:archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.

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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e7646dea..9b7cb4af 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index b740cf13..249efbb0 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,10 +15,12 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -27,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -112,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -170,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -203,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 7101f8e4..03754c4c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,16 +13,45 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -43,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -57,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel -:omega +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/build-logic/src/main/kotlin/olf.build-logic.gradle.kts b/openspec/changes/archive/.gitkeep similarity index 100% rename from build-logic/src/main/kotlin/olf.build-logic.gradle.kts rename to openspec/changes/archive/.gitkeep diff --git a/openspec/changes/archive/2026-10-09-add-test-foundation/.openspec.yaml b/openspec/changes/archive/2026-10-09-add-test-foundation/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-add-test-foundation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-09-add-test-foundation/design.md b/openspec/changes/archive/2026-10-09-add-test-foundation/design.md new file mode 100644 index 00000000..c47cba9e --- /dev/null +++ b/openspec/changes/archive/2026-10-09-add-test-foundation/design.md @@ -0,0 +1,30 @@ +# Design + +## Context + +Paper API 26.x has no MockBukkit release; MockBukkit's newest artifact targets 1.21. + +## Goals / Non-Goals + +**Goals:** fast tests without a server; a place to add tests in every later change. + +**Non-Goals:** testing painting results, GUI clicks or listeners end to end. + +## Decisions + +### Mockito for Bukkit interfaces instead of MockBukkit +`World`, `Player` and friends are interfaces and can be mocked. Classes that call `Bukkit.*` +statically are out of reach until the dependency-injection change removes those calls. +*Alternative:* MockBukkit 1.21 against Paper 26 API — rejected, mismatched API at runtime. + +### FAWE on the test classpath +Brush classes reference WorldEdit types in their signatures, so loading them needs FAWE core and +bukkit (non-transitive) as `testImplementation`. + +### Translation test reads the source +The test scans `src/main/java` for `translatable("bettergopaint.…")` and checks the keys against +every bundle, so it keeps working when new languages arrive through Crowdin. + +## Risks / Trade-offs + +- [Low coverage at first] → each later change adds tests for the code it touches. diff --git a/openspec/changes/archive/2026-10-09-add-test-foundation/proposal.md b/openspec/changes/archive/2026-10-09-add-test-foundation/proposal.md new file mode 100644 index 00000000..079cc1ed --- /dev/null +++ b/openspec/changes/archive/2026-10-09-add-test-foundation/proposal.md @@ -0,0 +1,33 @@ +# Proposal + +## Why + +BetterGoPaint has no tests. Every change, including the Paper 26 upgrade, can only be checked by +starting a server and painting by hand. Translation bundles and the brush registry break silently. +AntiRedstoneClock-Remastered runs JUnit tests with coverage on every pull request; BetterGoPaint +needs the same baseline before larger refactors (commands, GUI, dependency injection) start. + +## What Changes + +- JUnit 6, AssertJ and Mockito on the test classpath; JaCoCo XML and HTML coverage reports after + every test run. +- First tests for logic that does not need a server: brush cycling and lookup + (`PlayerBrushManager`), the Bézier curve of the Paint Brush, and the translation bundles. +- `./gradlew build` runs the tests and the license-header check (Spotless). +- docs/development/testing.md describes how to run and add tests. + +Out of scope: MockBukkit (no release for Paper 26 yet), integration tests that start a server, +coverage thresholds. + +## Capabilities + +### New Capabilities +- `build-verification`: The automated checks every build runs before code can be merged or released. + +### Modified Capabilities + +## Impact + +- `build.gradle.kts` (test dependencies, JaCoCo, Spotless target), `src/test/java/**`, + `docs/development/testing.md`. +- CI time grows by the test run (seconds). diff --git a/openspec/changes/archive/2026-10-09-add-test-foundation/specs/build-verification/spec.md b/openspec/changes/archive/2026-10-09-add-test-foundation/specs/build-verification/spec.md new file mode 100644 index 00000000..b4b63433 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-add-test-foundation/specs/build-verification/spec.md @@ -0,0 +1,37 @@ +# Spec Delta + +## Purpose + +Defines the automated checks that every BetterGoPaint build runs, so regressions in brush logic, +translations and source hygiene are caught before a merge or release. + +## ADDED Requirements + +### Requirement: Tests run with every build +The build SHALL run all unit tests, and a failing test SHALL fail the build. + +#### Scenario: Broken brush cycling +- **WHEN** cycling forward from the last brush no longer returns the first brush +- **THEN** `./gradlew build` fails and names the failing test + +### Requirement: Coverage report +Every test run SHALL produce a JaCoCo coverage report in XML and HTML. + +#### Scenario: Report after tests +- **WHEN** `./gradlew test` finishes +- **THEN** `build/reports/jacoco/test/jacocoTestReport.xml` exists + +### Requirement: Translation keys are complete +Every translation key used in the source SHALL exist in every shipped language bundle, and every +message SHALL parse as MiniMessage. + +#### Scenario: Key missing from a bundle +- **WHEN** code references `bettergopaint.notify.new` and a bundle lacks that key +- **THEN** the build fails and names the bundle and key + +### Requirement: License headers +Every Java source file SHALL carry the GPL license header from `HEADER.txt`. + +#### Scenario: New file without header +- **WHEN** a Java file without the header is added +- **THEN** `./gradlew build` fails in `spotlessJavaCheck` and `./gradlew spotlessApply` adds the header diff --git a/openspec/changes/archive/2026-10-09-add-test-foundation/tasks.md b/openspec/changes/archive/2026-10-09-add-test-foundation/tasks.md new file mode 100644 index 00000000..9918ad83 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-add-test-foundation/tasks.md @@ -0,0 +1,18 @@ +# Tasks + +## 1. Test infrastructure + +- [x] 1.1 Add JUnit 6, AssertJ, Mockito, Paper API and FAWE to the test classpath; verify `./gradlew test` runs +- [x] 1.2 Enable JaCoCo XML and HTML reports; verify `build/reports/jacoco/test/jacocoTestReport.xml` exists after `./gradlew test` +- [x] 1.3 Restrict Spotless to `src/**/*.java`; verify `./gradlew spotlessCheck` passes + +## 2. First tests + +- [x] 2.1 Add `PlayerBrushManagerTest` for cycling, wrap-around, lookup by name and lore highlighting; verify 6 tests pass +- [x] 2.2 Add `BezierSplineTest` for straight lines, end knots, flat curves and `getT`; verify 4 tests pass +- [x] 2.3 Add `TranslationBundleTest` for key completeness and MiniMessage parsing; verify it fails when a key is removed from `bettergopaint.properties` +- [x] 2.4 Write docs/development/testing.md; verify the commands in it run as written + +## Workflow follow-up + +- Archive the change after the pull request is merged. diff --git a/openspec/changes/archive/2026-10-09-adopt-release-pipeline/.openspec.yaml b/openspec/changes/archive/2026-10-09-adopt-release-pipeline/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-adopt-release-pipeline/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-09-adopt-release-pipeline/design.md b/openspec/changes/archive/2026-10-09-adopt-release-pipeline/design.md new file mode 100644 index 00000000..e949ebaf --- /dev/null +++ b/openspec/changes/archive/2026-10-09-adopt-release-pipeline/design.md @@ -0,0 +1,44 @@ +# Design + +## Context + +AntiRedstoneClock-Remastered's workflows are proven in production (they recovered from a failed +2.9.0 publish). BetterGoPaint copies them with project names, Java 25 and jar paths adjusted. + +## Goals / Non-Goals + +**Goals:** identical release mechanics across OneLiteFeather plugins; nothing public before the gate. + +**Non-Goals:** snapshot channels on Hangar; Maven publishing of the plugin. + +## Decisions + +### Release Please on `main` +The repository has `develop` as default branch plus stale `main`/`master`. Release Please needs one +release branch; `main` matches the other OneLiteFeather repositories. `develop` becomes obsolete. +*Alternative:* run on `develop` — rejected, it diverges from the organisation convention. + +### Version lives in `build.gradle.kts` +`version = "1.1.0" // x-release-please-version`, updated through the `generic` extra-file updater. +The release job refuses to publish when the tag and the declared version disagree. +`bootstrap-sha` is the current `develop` head so the first changelog does not replay 2023 history. + +### Exclude the unshaded jar +The build produces `BetterGoPaint-.jar` (shaded) and `-unshaded.jar`. Uploads exclude the +unshaded one so users cannot download a jar that misses its libraries. + +### Shared workflows pinned to `v2.9.0` +Security scan and SBOM publishing come from `OneLiteFeatherNET/workflows`, pinned by tag; Renovate +updates the pin. + +## Risks / Trade-offs + +- [Dependency-Track parent UUID copied from AntiRedstoneClock] → confirm the parent project with the + Dependency-Track admin before the first release. +- [Default branch still `develop`] → manual step in tasks.md. + +## Migration Plan + +1. Merge this change into `develop`, fast-forward `main`, switch the default branch to `main`. +2. Make sure `HANGAR_KEY` and `MODRINTH_KEY` exist as repository secrets. +3. The next `feat`/`fix` merge opens the first release pull request. diff --git a/openspec/changes/archive/2026-10-09-adopt-release-pipeline/proposal.md b/openspec/changes/archive/2026-10-09-adopt-release-pipeline/proposal.md new file mode 100644 index 00000000..d2470fd1 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-adopt-release-pipeline/proposal.md @@ -0,0 +1,47 @@ +# Proposal + +## Why + +BetterGoPaint publishes every push to `master`/`develop` straight to Hangar and Modrinth, has no +commit conventions, no changelog automation, no dependency or artifact scanning, and its Renovate +config disables semantic commits. AntiRedstoneClock-Remastered already runs a release process +where versions, changelog and publishing follow from Conventional Commits and nothing public +happens before a security gate. BetterGoPaint should work the same way so both plugins are +maintained with one set of habits. + +## What Changes + +- Conventional Commits enforced on every pull request title and commit (`pr-lint` workflow, + `commitlint.config.mjs`). +- Release Please on `main`: opens a release pull request, and merging it bumps + `version` in `build.gradle.kts`, writes `CHANGELOG.md`, tags `vX.Y.Z` and creates the GitHub release. +- Release build → Trivy security gate on the built jar → upload to GitHub release, Hangar and + Modrinth → CycloneDX SBOM to Dependency-Track, each as its own job. A `workflow_dispatch` path + re-publishes an existing tag. +- Pull request build on Linux, Windows and macOS with Java 25, test result publication and an + optional PR jar (`publish-pr` label). +- Weekly Trivy scan of the repository. +- **BREAKING** (for maintainers) Remove `publish.yml` (snapshot publishing on every push), + release-drafter and the stale bot. +- Renovate uses the shared OneLiteFeatherNET presets. +- New issue forms (bug report with FAWE debugpaste, feature request), the organisation pull + request template, CONTRIBUTING.md explaining commits and releases. + +Out of scope: the GitHub repository settings (default branch, branch protection, secrets) — they +are listed as manual steps in tasks.md. + +## Capabilities + +### New Capabilities +- `release-pipeline`: How a change becomes a published, versioned and scanned release. + +### Modified Capabilities + +## Impact + +- `.github/workflows/*`, `.github/ISSUE_TEMPLATE/*`, `.github/pull_request_template.md`, + `release-please-config.json`, `.release-please-manifest.json`, `commitlint.config.mjs`, + `renovate.json`, `CONTRIBUTING.md`. +- Secrets required: `HANGAR_KEY`, `MODRINTH_KEY`, plus whatever the shared + `OneLiteFeatherNET/workflows` security and SBOM workflows inherit. +- Snapshot builds are no longer published to Hangar/Modrinth; testers use the PR jar instead. diff --git a/openspec/changes/archive/2026-10-09-adopt-release-pipeline/specs/release-pipeline/spec.md b/openspec/changes/archive/2026-10-09-adopt-release-pipeline/specs/release-pipeline/spec.md new file mode 100644 index 00000000..5f4234fd --- /dev/null +++ b/openspec/changes/archive/2026-10-09-adopt-release-pipeline/specs/release-pipeline/spec.md @@ -0,0 +1,59 @@ +# Spec Delta + +## Purpose + +Describes how changes to BetterGoPaint turn into a versioned release, which checks gate it and +where it is published. + +## ADDED Requirements + +### Requirement: Conventional commit messages +Every pull request title and every commit in a pull request SHALL follow Conventional Commits with +one of the types build, chore, ci, docs, feat, fix, perf, refactor, revert, style or test. + +#### Scenario: Non-conventional title +- **WHEN** a pull request is opened with the title "Update stuff" +- **THEN** the pr-lint check fails and names the expected format + +#### Scenario: Conventional title +- **WHEN** a pull request is opened with the title "fix: keep the gradient brush inside the mask" +- **THEN** the pr-lint title check passes + +### Requirement: Version derived from commits +The next version SHALL be derived from the commits since the last release: a breaking change bumps +MAJOR, `feat` bumps MINOR, `fix` bumps PATCH, other types do not release. + +#### Scenario: Feature merged +- **WHEN** the last release is 1.1.0 and a `feat:` commit is merged into main +- **THEN** the release pull request targets 1.2.0 + +### Requirement: Releases happen only by merging the release pull request +Pushing to main SHALL NOT publish anything. Merging the release pull request SHALL tag the version, +update the changelog and start publishing. + +#### Scenario: Ordinary merge +- **WHEN** a feature pull request is merged into main +- **THEN** no artifact is uploaded to Hangar or Modrinth and the release pull request is updated + +### Requirement: Security gate before publishing +The built jar SHALL be scanned before any public upload, and a HIGH or CRITICAL finding SHALL stop +the release. + +#### Scenario: Vulnerable dependency shaded +- **WHEN** the release jar contains a dependency with a CRITICAL CVE +- **THEN** the publish job does not run and nothing is uploaded + +### Requirement: Published artifacts +A successful release SHALL attach the shaded jar to the GitHub release and publish the same jar to +Hangar and Modrinth for every supported Minecraft version, and SHALL upload a CycloneDX SBOM. + +#### Scenario: Hangar rejects the upload +- **WHEN** Hangar rejects the upload +- **THEN** the jar is still attached to the GitHub release and the tag can be re-published with workflow_dispatch + +### Requirement: Pull request builds +Every pull request SHALL be built and tested on Linux, Windows and macOS. + +#### Scenario: Test fails on one OS +- **WHEN** a test fails on Windows only +- **THEN** the pull request build is red diff --git a/openspec/changes/archive/2026-10-09-adopt-release-pipeline/tasks.md b/openspec/changes/archive/2026-10-09-adopt-release-pipeline/tasks.md new file mode 100644 index 00000000..e4eaea94 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-adopt-release-pipeline/tasks.md @@ -0,0 +1,33 @@ +# Tasks + +## 1. Commit conventions + +- [x] 1.1 Add `commitlint.config.mjs` and the `pr-lint` workflow; verify `npx commitlint --from HEAD~1` accepts a `feat:` commit +- [x] 1.2 Document the commit types and their effect on the version in CONTRIBUTING.md + +## 2. Release automation + +- [x] 2.1 Add `release-please-config.json`, `.release-please-manifest.json` (1.1.0) and the `x-release-please-version` marker; verify the marker is on the `version =` line of `build.gradle.kts` +- [x] 2.2 Add `release-please.yml` with build, security gate, publish and SBOM jobs; verify `./gradlew check shadowJar cyclonedxBom` produces `build/libs/BetterGoPaint-.jar` and `build/reports/cyclonedx/bom.xml` +- [x] 2.3 Remove `publish.yml`, release-drafter and stale config; verify no workflow publishes on push + +## 3. Pull request checks and hygiene + +- [x] 3.1 Port `build-pr.yml` with Java 25 and the unshaded jar excluded from the PR artifact +- [x] 3.2 Add the weekly `security.yml` Trivy scan +- [x] 3.3 Switch `renovate.json` to the shared OneLiteFeatherNET presets and remove `.github/renovate.json` +- [x] 3.4 Replace issue templates and the pull request template; verify every form and workflow parses as YAML + + +## Manual follow-up (maintainers, GitHub settings) + +Not tracked: these are repository settings, not code. + +- Bring `main` up to date and make it the default branch +- Protect `main` and require `Build Pull Request Branch`, `Conventional PR title` and `Conventional commits` +- Verify `HANGAR_KEY`, `MODRINTH_KEY` and the Dependency-Track secrets exist +- Confirm the Dependency-Track `parent-uuid` in `release-please.yml` + +## Workflow follow-up + +- Archive the change after the first release went through the new pipeline. diff --git a/openspec/changes/archive/2026-10-09-folia-ready-scheduling/.openspec.yaml b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-09-folia-ready-scheduling/design.md b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/design.md new file mode 100644 index 00000000..57f76f1c --- /dev/null +++ b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/design.md @@ -0,0 +1,34 @@ +# Design + +## Context + +Paper exposes the Folia scheduler API (`Bukkit.getAsyncScheduler()`, +`Bukkit.getGlobalRegionScheduler()`, `Entity#getScheduler()`) on regular Paper too, so one code +path serves both. Event handlers on Folia already run on the region thread that owns the player, +so opening the brush menu from `PlayerInteractEvent` needs no rescheduling. + +## Decisions + +### No scheduler abstraction layer +AntiRedstoneClock needs a `SchedulerService` because it supports Spigot-era APIs. BetterGoPaint is +Paper-only, and Paper's unified API already behaves correctly on both servers. *Alternative:* copy +AntiRedstoneClock's `SchedulerService`. Rejected as an extra layer without a second implementation. + +### `ConcurrentHashMap` for per-player state +Writes happen on the player's region thread, reads can come from commands or the FAWE worker. A +concurrent map is enough. Each `PlayerBrush` is only mutated by its owner's thread. + +### Gradle property for the Folia flag +`foliaSupported = providers.gradleProperty("foliaSupported").map(String::toBoolean).orElse(false)`. +Release builds never set it, so a test flag cannot leak into a release by accident. + +### Folia test server with a configurable FAWE URL +`run-folia-` uses run-paper's Folia download service and installs FAWE from the URL in +the Gradle property `foliaFaweUrl`. Without that property the task fails with a message explaining +what to set. + +## Risks / Trade-offs + +- [Community FAWE forks behave differently from upstream] → used only for local testing, never + shipped or documented as supported. +- [bStats on Folia] → bStats 3.x schedules through its own Folia-aware logic; verify on the test server. diff --git a/openspec/changes/archive/2026-10-09-folia-ready-scheduling/proposal.md b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/proposal.md new file mode 100644 index 00000000..0e2dd7aa --- /dev/null +++ b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/proposal.md @@ -0,0 +1,42 @@ +# Proposal + +## Why + +Folia runs each region of the world on its own thread and rejects code that assumes a single main +thread. BetterGoPaint schedules its update check with the legacy `BukkitScheduler`, which throws +on Folia. It also keeps per-player state in plain `HashMap`s that are written from several threads, +including a static map in the Paint Brush. Once brush strokes no longer read the Bukkit world +(`native-worldedit-brushes`), these are the remaining blockers. + +Folia cannot be declared supported yet. Stable FastAsyncWorldEdit, which BetterGoPaint requires, +does not support Folia; only experimental upstream work and community forks do. BetterGoPaint +should be ready, so that switching the flag is the only step left when FAWE ships it. + +## What Changes + +- Use Paper's region-aware scheduler API, which exists on both Paper and Folia: the async scheduler + for the update check, the entity scheduler for anything that touches a player, the global region + scheduler for server-wide work. +- Per-player state (`PlayerBrushManager`, Paint Brush points) becomes thread-safe, and the Paint + Brush points move from a static map into the player's brush state. +- `foliaSupported` in `paper-plugin.yml` is driven by a Gradle property that defaults to `false`. +- `run-folia-` tasks start a Folia test server with a Folia-capable FAWE build from a + configurable URL, for testing only. +- docs/explanation/scope-and-non-goals.md and the supported-versions page state the Folia status. + +Out of scope: declaring Folia support, publishing a Folia loader on Hangar or Modrinth, changing +FastAsyncWorldEdit. + +## Capabilities + +### New Capabilities +- `server-threading`: Which threads BetterGoPaint runs on, how it schedules work, and under which + condition it declares Folia support. + +### Modified Capabilities + +## Impact + +- `service/UpdateService.java`, `brush/PlayerBrushManager.java`, `brush/PlayerBrush.java`, + `objects/brush/PaintBrush.java`, `build.gradle.kts`, `gradle.properties`, docs. +- Depends on `native-worldedit-brushes` being applied first. diff --git a/openspec/changes/archive/2026-10-09-folia-ready-scheduling/specs/server-threading/spec.md b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/specs/server-threading/spec.md new file mode 100644 index 00000000..9db9df04 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/specs/server-threading/spec.md @@ -0,0 +1,35 @@ +# Spec Delta + +## Purpose + +Defines how BetterGoPaint schedules work and shares state across threads, so that it runs on Paper +today and on Folia as soon as its required dependencies do. + +## ADDED Requirements + +### Requirement: Region-aware scheduling only +The plugin SHALL schedule work only through Paper's async, global region and entity schedulers, and +SHALL NOT use the legacy `BukkitScheduler`. + +#### Scenario: Update check on Folia +- **WHEN** the plugin starts on a Folia server with update checks enabled +- **THEN** the update check is scheduled without an `UnsupportedOperationException` + +### Requirement: Thread-safe per-player state +Per-player brush state SHALL be safe to read and write from different threads at the same time. + +#### Scenario: Two players paint in different regions +- **WHEN** two builders in different Folia regions paint and change brush settings at the same time +- **THEN** each keeps their own settings and no state is lost or corrupted + +### Requirement: Folia declared only with a Folia-capable FAWE +The published plugin SHALL NOT declare Folia support while no stable FastAsyncWorldEdit release +supports Folia. Declaring it SHALL require changing only one build property. + +#### Scenario: Default build +- **WHEN** the plugin is built without extra properties +- **THEN** the generated `paper-plugin.yml` does not declare `folia-supported: true` + +#### Scenario: Opt-in test build +- **WHEN** the plugin is built with `-PfoliaSupported=true` +- **THEN** the generated `paper-plugin.yml` declares `folia-supported: true` diff --git a/openspec/changes/archive/2026-10-09-folia-ready-scheduling/tasks.md b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/tasks.md new file mode 100644 index 00000000..240eec82 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-folia-ready-scheduling/tasks.md @@ -0,0 +1,21 @@ +# Tasks + +## 1. Scheduling + +- [x] 1.1 Move the update check to `Bukkit.getAsyncScheduler().runAtFixedRate`; verify `grep -rn "getScheduler()" src/main/java` finds no `BukkitScheduler` use and `UpdateServiceTest` passes + +## 2. Shared state + +- [x] 2.1 Make `PlayerBrushManager` use a `ConcurrentHashMap`; verify with a unit test that concurrent `getBrush` calls for one UUID return the same instance +- [x] 2.2 Move Paint Brush points from the static map into per-player state that is cleared on quit; verify with a unit test + +## 3. Folia switch + +- [x] 3.1 Drive `foliaSupported` from the `foliaSupported` Gradle property (default false); verify the generated `paper-plugin.yml` with and without `-PfoliaSupported=true` +- [x] 3.2 Add `run-folia-` tasks that install FAWE from `-PfoliaFaweUrl`; verify the task fails with a clear message when the property is missing +- [x] 3.3 Document the Folia status in docs/explanation/scope-and-non-goals.md and docs/reference/supported-versions.md, and how to test on Folia in docs/development/testing.md + +## Manual follow-up + +- Test on Folia with `-PfoliaSupported=true -PfoliaFaweUrl=` and a game client. +- Flip the default once a stable FastAsyncWorldEdit release declares Folia support. diff --git a/openspec/changes/archive/2026-10-09-harden-brush-menu/.openspec.yaml b/openspec/changes/archive/2026-10-09-harden-brush-menu/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-harden-brush-menu/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-09-harden-brush-menu/design.md b/openspec/changes/archive/2026-10-09-harden-brush-menu/design.md new file mode 100644 index 00000000..b1f9515f --- /dev/null +++ b/openspec/changes/archive/2026-10-09-harden-brush-menu/design.md @@ -0,0 +1,28 @@ +# Design + +## Decisions + +### `InventoryHolder` subclasses for menus +`BrushMenuHolder` and `BrushPickerHolder` own their inventories; the listener checks +`getTopInventory().getHolder(false) instanceof BrushMenuHolder`. Title text stops mattering, which +also unblocks translation. *Alternative:* track open inventories in a map — rejected, needs cleanup +on close and quit. + +### Persistent data with a versioned string +`ExportedBrushCodec` encodes an `ExportedPlayerBrush` as `version=1;brush=…;size=…` and stores it as +a `STRING` under `bettergopaint:brush`. Keeping the codec independent of `PersistentDataContainer` +lets unit tests round-trip it without a server. *Alternative:* a nested `TAG_CONTAINER` per field — +rejected, it needs a live container implementation to test. + +### Render menu text server-side +Menu items are rendered with `GlobalTranslator.render` for the locale of the player who owns the +menu, so translations do not depend on the client rendering translatable components in item +names. Brush names and descriptions stay English for now. + +### Legacy parser kept, but total +The lore parser stays only for conversion, wraps every field in its own parse with a default, and +is deleted one major version later. + +## Risks / Trade-offs + +- [Exported items from 1.x lost if conversion fails] → conversion falls back to defaults rather than dropping the brush. diff --git a/openspec/changes/archive/2026-10-09-harden-brush-menu/proposal.md b/openspec/changes/archive/2026-10-09-harden-brush-menu/proposal.md new file mode 100644 index 00000000..3aa163a6 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-harden-brush-menu/proposal.md @@ -0,0 +1,36 @@ +# Proposal + +## Why + +The brush menu is recognised by comparing the inventory title text with "goPaint Menu", so any +other plugin or a renamed chest with that title is treated as the brush menu and clicks in it are +cancelled. Brush selection parses display names with legacy `§6` colour codes, and exported brushes +store their settings as lore text that is parsed back with `substring` and `Integer.parseInt` — a +renamed or edited item throws an exception on right-click. Both paths use deprecated Paper API +(`getDisplayName`, `getLore`) and `ExportedPlayerBrush.parse` is already marked for removal. + +## What Changes + +- Identify the brush menu and the brush picker by their `InventoryHolder`, not by title text. +- Store exported brush settings in the item's `PersistentDataContainer` under a + `bettergopaint:brush` key; lore becomes display only. +- Exported items from 1.x (lore-only) are read once through the legacy parser, converted to the + new format and never throw: unreadable values fall back to defaults. +- Menu titles, item names and lore use Adventure components and translation keys instead of `§` + codes. + +Out of scope: a new menu layout, new brush settings. + +## Capabilities + +### New Capabilities +- `brush-menu`: The inventory menu that edits a player's brush and how clicks in it are handled. +- `exported-brushes`: Items that carry a brush configuration and paint with it. + +### Modified Capabilities + +## Impact + +- `utils/GUI.java`, `utils/Items.java`, `listeners/InventoryListener.java`, + `listeners/InteractListener.java`, `brush/ExportedPlayerBrush.java`, `brush/PlayerBrush.java`, + `bettergopaint.properties`, docs/how-to/share-a-brush-as-an-item.md. diff --git a/openspec/changes/archive/2026-10-09-harden-brush-menu/specs/brush-menu/spec.md b/openspec/changes/archive/2026-10-09-harden-brush-menu/specs/brush-menu/spec.md new file mode 100644 index 00000000..901ec078 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-harden-brush-menu/specs/brush-menu/spec.md @@ -0,0 +1,35 @@ +# Spec Delta + +## Purpose + +Defines how the brush menu and the brush picker are opened and identified, and that clicks are only +intercepted in those inventories. + +## ADDED Requirements + +### Requirement: Menu identity does not depend on the title +The plugin SHALL handle clicks only in inventories it created for the brush menu or brush picker, +regardless of the inventory title. + +#### Scenario: Foreign inventory with the same title +- **WHEN** another plugin opens a chest titled "goPaint Menu" and the player clicks in it +- **THEN** BetterGoPaint does not cancel or handle the click + +#### Scenario: Translated title +- **WHEN** the menu title is translated into German +- **THEN** clicks in the menu still change the brush settings + +### Requirement: Player inventory stays usable +While the brush menu is open, clicks in the player's own inventory SHALL only be cancelled for +shift-clicks and collect-to-cursor, which would move items into the menu. + +#### Scenario: Normal click in own inventory +- **WHEN** the brush menu is open and the player left-clicks an item in their own inventory +- **THEN** the item is picked up normally + +### Requirement: Translated menu text +Menu titles, item names and lore SHALL be resolved from the translation bundles. + +#### Scenario: German client +- **WHEN** a German client opens the brush menu and a German bundle is installed +- **THEN** titles and item texts are German diff --git a/openspec/changes/archive/2026-10-09-harden-brush-menu/specs/exported-brushes/spec.md b/openspec/changes/archive/2026-10-09-harden-brush-menu/specs/exported-brushes/spec.md new file mode 100644 index 00000000..e89c813b --- /dev/null +++ b/openspec/changes/archive/2026-10-09-harden-brush-menu/specs/exported-brushes/spec.md @@ -0,0 +1,32 @@ +# Spec Delta + +## Purpose + +Defines how a brush configuration is stored on an item, how such an item paints, and how it behaves +when the stored data is missing or invalid. + +## ADDED Requirements + +### Requirement: Brush stored as item data +Exporting a brush onto an item SHALL store the brush type, size, palette and every brush setting as +structured item data, independent of the item's name and lore. + +#### Scenario: Renamed item keeps working +- **WHEN** a player exports a brush onto a stick and renames the stick in an anvil +- **THEN** right-clicking with the stick paints with the exported settings + +### Requirement: Invalid data never breaks painting +An item with missing or unreadable brush data SHALL be treated as a normal item, and invalid single +values SHALL fall back to the configured defaults. + +#### Scenario: Edited lore +- **WHEN** a legacy exported item's lore line `Size: 10` was edited to `Size: ten` +- **THEN** right-clicking paints with the default size and no exception is logged + +### Requirement: Legacy items are converted +Items exported by BetterGoPaint 1.x SHALL keep working and SHALL be converted to the new format the +first time they are used. + +#### Scenario: Item from 1.1.0 +- **WHEN** a player right-clicks with a brush item exported by 1.1.0 +- **THEN** it paints with the stored settings and afterwards carries the new item data diff --git a/openspec/changes/archive/2026-10-09-harden-brush-menu/tasks.md b/openspec/changes/archive/2026-10-09-harden-brush-menu/tasks.md new file mode 100644 index 00000000..98a80e49 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-harden-brush-menu/tasks.md @@ -0,0 +1,17 @@ +# Tasks + +## 1. Menu identity + +- [x] 1.1 Introduce `BrushMenuHolder` and `BrushPickerHolder` and create menus through them; verify with `InventoryListenerTest` that the listener ignores an inventory with the same title but another holder +- [x] 1.2 Replace `§` codes in `GUI` and `Items` with components and `bettergopaint.menu.*` keys rendered for the player's locale; verify `TranslationBundleTest` passes (it now scans every key literal) + +## 2. Exported brushes + +- [x] 2.1 Store `ExportedPlayerBrush` as a versioned string under `bettergopaint:brush` and write it on export; verify the round trip in `ExportedBrushCodecTest` +- [x] 2.2 Read brushes from item data in `InteractListener`, fall back to the total legacy parser and convert; verify `ExportedBrushCodecTest` for edited and unreadable values +- [x] 2.3 Remove the deprecated `getDisplayName`/`getLore` calls; verify `./gradlew compileJava` shows no deprecation warnings from these classes +- [x] 2.4 Update docs/how-to/share-a-brush-as-an-item.md + +## Workflow follow-up + +- Archive the change after the pull request is merged. diff --git a/openspec/changes/archive/2026-10-09-introduce-dependency-injection/.openspec.yaml b/openspec/changes/archive/2026-10-09-introduce-dependency-injection/.openspec.yaml new file mode 100644 index 00000000..ea7acf6a --- /dev/null +++ b/openspec/changes/archive/2026-10-09-introduce-dependency-injection/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-10-09 +skip_specs: true diff --git a/openspec/changes/archive/2026-10-09-introduce-dependency-injection/design.md b/openspec/changes/archive/2026-10-09-introduce-dependency-injection/design.md new file mode 100644 index 00000000..ea1fe027 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-introduce-dependency-injection/design.md @@ -0,0 +1,26 @@ +# Design + +## Context + +Reference: AntiRedstoneClock-Remastered `injection/*Module.java` and +docs/development/dependency-injection.md. + +## Decisions + +### Guice over manual constructor wiring +Same library and module layout as AntiRedstoneClock, so contributors move between the projects +without relearning. *Alternative:* manual wiring in `onEnable` — fewer dependencies, but grows into +the same God-object the plugin class is today. + +### Settings as provider +`Provider` keeps `/gopaint reload` cheap: a new immutable snapshot is published and +consumers read it per use instead of caching fields. + +### Relocate Guice +Guice and its transitive Guava/AOP alliance are relocated under `net.onelitefeather.bettergopaint.libs` +to avoid clashes with other plugins. + +## Risks / Trade-offs + +- [Jar size grows by ~1 MB] → acceptable, matches AntiRedstoneClock. +- [Big diff] → land it after the command and menu changes, module by module. diff --git a/openspec/changes/archive/2026-10-09-introduce-dependency-injection/proposal.md b/openspec/changes/archive/2026-10-09-introduce-dependency-injection/proposal.md new file mode 100644 index 00000000..3cdc92b0 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-introduce-dependency-injection/proposal.md @@ -0,0 +1,37 @@ +# Proposal + +## Why + +Settings are a static singleton, listeners and commands receive the whole plugin instance, and +`PlayerBrush` builds its own GUI through static helpers. Classes cannot be constructed in tests +without a running server, which keeps most of the code out of reach of the test foundation. +AntiRedstoneClock-Remastered solved the same problem with Guice modules and service interfaces. + +## What Changes + +- Guice (with `jakarta.inject`) wires the plugin in modules: settings, brushes, listeners, + commands, update service, metrics. +- `Settings.settings()` is replaced by an injected settings provider that is swapped atomically on + reload. The settings stop extending FAWE's `Config`: on Paper 26 that loader could not set the + plugin's instance fields (one "Failed to set config option" warning per key), so edits to + `config.yml` were ignored. A small YAML loader reads the same keys, writes them back with + comments and replaces invalid values with their defaults. +- Listeners and commands depend on narrow services (`BrushService`, `MenuService`, + `TranslationService`) instead of `BetterGoPaint`. +- A DI validation test builds the injector with mocked Bukkit services. + +No new features. The only visible effect is that `config.yml` is honoured again. Out of scope: Folia support. + +## Capabilities + +### New Capabilities + +### Modified Capabilities + +None — pure refactor; `skip_specs: true`. + +## Impact + +- Most classes under `net.onelitefeather.bettergopaint`; new `injection` package. +- New shaded dependencies: Guice 7, jakarta.inject 2 (relocated). +- docs/development/dependency-injection.md (new). diff --git a/openspec/changes/archive/2026-10-09-introduce-dependency-injection/tasks.md b/openspec/changes/archive/2026-10-09-introduce-dependency-injection/tasks.md new file mode 100644 index 00000000..b34b28ac --- /dev/null +++ b/openspec/changes/archive/2026-10-09-introduce-dependency-injection/tasks.md @@ -0,0 +1,16 @@ +# Tasks + +## 1. Container + +- [x] 1.1 Add Guice and jakarta.inject to the catalog, shade and relocate them; verify the jar contains no `com/google/inject`, `jakarta/inject` or `org/aopalliance` outside `net/onelitefeather/bettergopaint/libs` +- [x] 1.2 Create `PlatformModule`, `SettingsModule`, `BrushModule`, `ListenerModule`, `CommandModule`, `ServiceModule`; verify `DIValidationTest` builds the injector with a mocked plugin + +## 2. Migration + +- [x] 2.1 Replace `Settings.settings()` (FAWE `Config`) with immutable records, `SettingsLoader` and the injected `SettingsHolder`; verify `SettingsHolderTest` (defaults, invalid values, reload swaps the snapshot) +- [x] 2.2 Inject services into listeners and commands instead of `BetterGoPaint`; verify unit tests for `InteractListener` permission and world checks +- [x] 2.3 Write docs/development/dependency-injection.md + +## Workflow follow-up + +- Archive the change after the pull request is merged. diff --git a/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/.openspec.yaml b/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/design.md b/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/design.md new file mode 100644 index 00000000..41671d3b --- /dev/null +++ b/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/design.md @@ -0,0 +1,24 @@ +# Design + +## Context + +Cloud 2.x is already shaded. Registration currently uses `LegacyPaperCommandManager`; AntiRedstoneClock +uses the same library with annotations and a Brigadier mapping. + +## Decisions + +### `PaperCommandManager` with Brigadier +Paper plugins get first-class Brigadier registration through cloud-paper's modern manager, which also +gives client-side validation of integer ranges. *Alternative:* keep `LegacyPaperCommandManager` — +rejected, it registers through the Bukkit command map and loses argument types. + +### Range from config at parse time +The upper bound of `size` is read from settings when the command is parsed, so a reload changes it +without re-registering commands. + +### `bettergopaint.admin` as bundle +Keeps existing permission setups working while giving each subcommand a fine-grained node. + +## Risks / Trade-offs + +- [Permission plugins keyed on `bettergopaint.admin`] → bundle keeps it working; release notes mention the new nodes. diff --git a/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/proposal.md b/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/proposal.md new file mode 100644 index 00000000..489b9ef6 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/proposal.md @@ -0,0 +1,35 @@ +# Proposal + +## Why + +`/gopaint` is a hand-written Bukkit `Command` with nested `if` chains, hard-coded English text, a +wrong usage hint (`/gb size`), no tab completion and no number validation. At the same time a +second, cloud-based `/bgp|gp reload` is registered, so `/gp` is claimed by two command systems with +different permissions. AntiRedstoneClock-Remastered runs all commands through cloud annotations +with Brigadier and translatable messages. + +## What Changes + +- One command tree `/gopaint` (aliases `/gp`, `/bgp`) built with cloud annotations and Paper's + Brigadier integration; the Bukkit `Command` subclass is removed. +- `size <1..max-size>` with tab completion and range validation, `toggle`, `info`, `reload`, and a + generated help. +- Each subcommand has its own permission; `bettergopaint.use` stays as the base permission. +- Every message comes from the translation bundle (`bettergopaint.command.*` keys). +- **BREAKING** `/gopaint reload` requires `bettergopaint.command.admin.reload` instead of + `bettergopaint.admin`; `bettergopaint.admin` becomes a bundle permission that grants it. + +Out of scope: new subcommands, console support for player-only commands. + +## Capabilities + +### New Capabilities +- `commands`: The `/gopaint` command tree, its arguments, permissions and feedback. + +### Modified Capabilities + +## Impact + +- `command/GoPaintCommand.java`, `command/ReloadCommand.java`, command registration in + `BetterGoPaint.java`, `bettergopaint.properties`, `build.gradle.kts` permissions, + docs/reference/commands.md and permissions.md. diff --git a/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/specs/commands/spec.md b/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/specs/commands/spec.md new file mode 100644 index 00000000..bce2f0d6 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/specs/commands/spec.md @@ -0,0 +1,42 @@ +# Spec Delta + +## Purpose + +Defines the `/gopaint` command tree: which subcommands exist, which arguments and permissions they +take, and what feedback players receive. + +## ADDED Requirements + +### Requirement: Single command tree +The plugin SHALL register exactly one command `/gopaint` with the aliases `/gp` and `/bgp`. + +#### Scenario: Alias resolves +- **WHEN** a player runs `/bgp toggle` +- **THEN** the same handler as `/gopaint toggle` runs + +### Requirement: Brush size argument +`/gopaint size ` SHALL accept integers from 1 to `generic.max-size` and SHALL reject other +input with a translated error that names the allowed range. + +#### Scenario: Too large +- **WHEN** `generic.max-size` is 100 and a player runs `/gopaint size 500` +- **THEN** the size is unchanged and the player is told the allowed range is 1–100 + +#### Scenario: Tab completion +- **WHEN** a player types `/gopaint size ` and presses tab +- **THEN** suggestions within the allowed range are offered + +### Requirement: Permission per subcommand +Each subcommand SHALL require its own permission in addition to `bettergopaint.use`, and the +generated help SHALL only list subcommands the sender may run. + +#### Scenario: Player without reload permission +- **WHEN** a player without `bettergopaint.command.admin.reload` runs `/gopaint help` +- **THEN** `reload` is not listed + +### Requirement: Translated feedback +Every message sent by a command SHALL be resolved from the translation bundles in the player's locale. + +#### Scenario: German client +- **WHEN** a player with a German client and a German bundle installed runs `/gopaint toggle` +- **THEN** the confirmation is shown in German diff --git a/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/tasks.md b/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/tasks.md new file mode 100644 index 00000000..911cce0c --- /dev/null +++ b/openspec/changes/archive/2026-10-09-migrate-commands-to-cloud/tasks.md @@ -0,0 +1,16 @@ +# Tasks + +## 1. Command tree + +- [x] 1.1 Switch to cloud-paper's `PaperCommandManager` with Brigadier; verify on `run-26.2` that `/gopaint`, `/gp info` and `/gopaint reload` answer from the console +- [x] 1.2 Rewrite `GoPaintCommand` as annotated handlers for size, toggle, info and reload, delete the Bukkit `Command` subclass and `ReloadCommand`; verify with `BrushSizesTest` that bounds and suggestions follow the configured maximum +- [x] 1.3 Add permissions per subcommand and the `bettergopaint.admin` bundle in `build.gradle.kts`; update docs/reference/permissions.md + +## 2. Messages + +- [x] 2.1 Move all command messages to `bettergopaint.command.*` keys; verify `TranslationBundleTest` passes +- [x] 2.2 Update docs/reference/commands.md with arguments, aliases and permissions + +## Workflow follow-up + +- Archive the change after the pull request is merged. diff --git a/openspec/changes/archive/2026-10-09-native-worldedit-brushes/.openspec.yaml b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-09-native-worldedit-brushes/design.md b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/design.md new file mode 100644 index 00000000..1717f07b --- /dev/null +++ b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/design.md @@ -0,0 +1,97 @@ +# Design + +## Context + +`Brush#performEdit` opens an `EditSession` from the player's `LocalSession` and writes through it, +but the target set is built from Bukkit `Block`s (`Sphere.getBlocksInRadius`, `Height`, `Surface`, +`ConnectedBlocks`, `Block#getType` in the mask check). `InteractListener` wraps the whole stroke in +`BukkitPlayer#runAction(…, async = true)`, so these Bukkit reads run on a FAWE worker thread. + +## Goals / Non-Goals + +**Goals:** no Bukkit world access during a stroke; strokes behave like WorldEdit brushes (mask, +limits, history); brush logic unit-testable against an in-memory extent. + +**Non-Goals:** WorldEdit tool binding (`/brush`), performance tuning beyond removing per-block +Bukkit round trips. + +## Decisions + +### Implement WorldEdit's `Brush` interface +Each brush becomes a `com.sk89q.worldedit.command.tool.brush.Brush` whose +`build(EditSession, BlockVector3, Pattern, double)` does the work. BetterGoPaint-specific inputs +(falloff, mixing, angle, surface mode, the player's eye position) go into an immutable +`StrokeContext` captured on the player's thread. *Alternative:* keep our own abstract `Brush` and +just swap the read calls. Rejected, because implementing the WorldEdit contract is what makes a +later `/brush` binding a small change. + +### Palette as `RandomPattern`, mask as `Mask` +The palette becomes a `RandomPattern` with weight 1 per block. The BetterGoPaint mask becomes a +`BlockTypeMask`, combined with the session's global mask that `LocalSession#createEditSession` +already applies. Materials are converted once with `BukkitAdapter.asBlockType` while the context is +captured, not per block. + +### Extent-based geometry helpers +`Sphere`, `Height`, `Surface` and `ConnectedBlocks` take an `Extent` plus `BlockVector3` and return +`BlockVector3`s. "Empty" means `BlockState#getBlockType().getMaterial().isAir()`. Min and max +height come from `EditSession#getMinY()/getMaxY()`. + +### Randomness +`StrokeContext` carries a `Random`. Production seeds it per stroke, tests pass a fixed seed. This +makes "same seed, same result" testable. + +### Tests against an in-memory extent +A small test `Extent` backed by a `Map` implements `getBlock`/`setBlock`. +If FAWE's interfaces make that impractical, tests use `BlockArrayClipboard` where it works offline. + +### Implementation notes (deviations from the decisions above) + +- **`Brush#bind` instead of an inheriting base.** Brush objects are shared singletons (the settings + of every player point at the same `SphereBrush`), so they cannot hold the context of a stroke and + also be WorldEdit's `Brush`. The base class keeps the brush metadata and gets + `apply(Extent, StrokeContext)`; `Brush#bind(StrokeContext)` returns a private + `com.sk89q.worldedit.command.tool.brush.Brush` whose `build(EditSession, BlockVector3, Pattern, + double)` hands position, pattern and size to the brush through `StrokeContext#with`. A later + `/brush` binding only has to produce a `StrokeContext`. +- **`apply` takes an `Extent`, not an `EditSession`.** An `EditSession` cannot be created without a + platform, and an `Extent` is all a brush needs. `build` wraps the session in a `MaskingExtent` + with the session mask, so no block the global mask excludes is written, whatever extent the + brush gets. +- **Own `SingleTypeMask` instead of `BlockTypeMask`.** `BlockTypeMask` sizes a lookup table from the + block registry when it is constructed, which fails without a server. `SingleTypeMask` extends + `AbstractExtentMask` and compares the block type directly. It starts without a real extent + (`NullExtent`), because the extent of the stroke is not known on the player's thread. The stroke + tests it with `test(Extent, BlockVector3)`. +- **Palette weights follow the list.** Palette entries are aggregated before they go into the + `RandomPattern`, so a material that is listed twice keeps twice the weight, as with the old + `randomBlock()`. The `RandomPattern` draws from the stroke's `Random` through a `SimpleRandom`. +- **Read first, write second.** FAWE's edit session shows earlier writes of the same stroke to later + reads, while the old code read the untouched Bukkit world. Every brush therefore collects its + positions first and writes afterwards, which keeps old and new results the same. The Gradient + brush draws its random numbers in the collect step and writes afterwards. +- **`BezierSpline` uses `Vector3`.** The public API takes and returns WorldEdit `Vector3`. Inside, + FAWE's `MutableVector3` replaces the mutable `Location`s of the control point maths, which is + pure arithmetic and unchanged. The unused `getWorld()` was removed because a curve has no world. +- **Solid and air checks.** Bukkit's `Block#isEmpty` becomes `BlockMaterial#isAir()` and + `Block#isSolid` becomes `BlockMaterial#isSolid()`. Positions outside the build limits read as + air and are never returned by `Sphere`. +- **Multi-click state stays on the player's thread.** `Brush#prepare` runs in the listener before + the stroke is queued. The Paint Brush collects its points there and returns the context with all + points, or null while the player is still clicking. +- **The eye position keeps the old 1.5 block offset** above the player's feet for the relative + surface mode, so existing builds look the same. + +### Test doubles +WorldEdit's `BlockTypes` needs the block registry of a running platform, so no real `BlockType` or +`BlockState` exists in a unit test. The tests use `MemoryExtent` (an `Extent` over a map of block +types) and Mockito mocks of `BlockType` that answer the default state and whether the material is +air or solid. `BlockVector3`, `Vector3`, `RandomPattern` and `Mask` work offline. A real +`EditSession` cannot be built either, so the tests call `Brush#apply` and check the session-mask +wrapper directly. History and `//gmask` on a real session stay in the manual check. + +## Risks / Trade-offs + +- [Subtle result differences per brush] → port brush by brush with a test each, and compare against + the old code on `run-26.2` with a client before release. +- [FAWE-specific API surface] → stay on `com.sk89q.worldedit` API where possible so the plugin is + not tied to FAWE internals. diff --git a/openspec/changes/archive/2026-10-09-native-worldedit-brushes/proposal.md b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/proposal.md new file mode 100644 index 00000000..c25bf930 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/proposal.md @@ -0,0 +1,46 @@ +# Proposal + +## Why + +Every brush computes its target blocks by reading the Bukkit world (`Location#getBlock`, +`Block#getType`, `World#getBlockAt`) and only writes through a FastAsyncWorldEdit edit session. +That code runs inside FAWE's asynchronous player action queue, so the world is read off the server +thread. On Paper this is unsafe and can read stale or half-loaded chunks. On Folia it is forbidden +outright. The plugin also ignores what the builder already set up in WorldEdit: the read path +bypasses `//gmask`, and block types are converted per block through `BukkitAdapter`. Reading and +writing through WorldEdit's own extent fixes the thread-safety problem and makes the plugin behave +like a WorldEdit brush. It is also the prerequisite for Folia (see `folia-ready-scheduling`). + +## What Changes + +- Brushes compute on WorldEdit types only: `EditSession`/`Extent` for reads and writes, + `BlockVector3` for positions, `BlockType`/`BlockState` for blocks. +- Each BetterGoPaint brush implements WorldEdit's brush contract (`build(EditSession, BlockVector3, + Pattern, double)`). The palette becomes a WorldEdit `RandomPattern`, and the BetterGoPaint mask + becomes a WorldEdit `Mask`. +- The geometry helpers (`Sphere`, `Height`, `Surface`, `ConnectedBlocks`) read from an `Extent` + instead of a Bukkit `World`. +- Everything that needs the player (eye location for the relative surface mode, the target block) + is captured on the player's thread before the stroke is queued. The queued work never touches + Bukkit objects. +- The stroke respects the builder's WorldEdit session: global mask (`//gmask`), FAWE limits, and one + history entry per stroke for `//undo`. + +Out of scope: binding BetterGoPaint brushes to arbitrary items through WorldEdit's `/brush` tool +system (possible follow-up), new brushes, changes to brush results beyond fixing reads of stale +data. + +## Capabilities + +### New Capabilities +- `brush-painting`: How a brush stroke reads and changes the world, and how it interacts with the + builder's WorldEdit session. + +### Modified Capabilities + +## Impact + +- `objects/brush/*`, `utils/Sphere.java`, `utils/Height.java`, `utils/Surface.java`, + `utils/ConnectedBlocks.java`, `utils/curve/*` (positions), `listeners/InteractListener.java`, + `brush/BrushSettings.java` (Materials converted to WorldEdit block types at the edge). +- docs/explanation/how-a-brush-stroke-is-applied.md. diff --git a/openspec/changes/archive/2026-10-09-native-worldedit-brushes/specs/brush-painting/spec.md b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/specs/brush-painting/spec.md new file mode 100644 index 00000000..11e38539 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/specs/brush-painting/spec.md @@ -0,0 +1,46 @@ +# Spec Delta + +## Purpose + +Defines how a brush stroke reads and modifies the world. Every stroke goes through the builder's +WorldEdit session, so it is thread-safe, undoable and limited like any other WorldEdit edit. + +## ADDED Requirements + +### Requirement: World access only through WorldEdit +A brush stroke SHALL read and write blocks only through the WorldEdit edit session of the stroke, +and SHALL NOT call Bukkit world, block or location methods while the stroke is computed. + +#### Scenario: Stroke computed in FAWE's queue +- **WHEN** a builder right-clicks with a brush and FAWE runs the stroke on a worker thread +- **THEN** no Bukkit world or block method is called on that thread + +### Requirement: One stroke, one undo step +Each brush stroke SHALL be recorded in the builder's WorldEdit history as exactly one entry. + +#### Scenario: Undo a stroke +- **WHEN** a builder paints one stroke and runs `//undo` +- **THEN** every block changed by that stroke is restored and nothing else + +### Requirement: WorldEdit global mask is respected +A brush stroke SHALL NOT change blocks that the builder's WorldEdit global mask excludes. + +#### Scenario: Global mask set +- **WHEN** a builder ran `//gmask grass_block` and paints with a sphere brush over grass and stone +- **THEN** only grass blocks are changed + +### Requirement: Palette and mask behave as before +A stroke SHALL place only blocks from the palette, chosen with equal weight, and with the +BetterGoPaint mask enabled it SHALL change only blocks of the mask type. + +#### Scenario: Mask enabled +- **WHEN** the palette is stone and andesite, the mask is grass block and a sphere brush hits grass and dirt +- **THEN** only grass blocks become stone or andesite + +### Requirement: Brush results are independent of the thread +For the same world, settings and random seed, a brush SHALL change the same blocks regardless of +which thread computes the stroke. + +#### Scenario: Deterministic test +- **WHEN** a brush runs twice against identical in-memory extents with the same seed +- **THEN** both runs change exactly the same positions to the same blocks diff --git a/openspec/changes/archive/2026-10-09-native-worldedit-brushes/tasks.md b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/tasks.md new file mode 100644 index 00000000..95281bb6 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-native-worldedit-brushes/tasks.md @@ -0,0 +1,25 @@ +# Tasks + +## 1. Foundation + +- [x] 1.1 Add `StrokeContext` (captured target, player eye position, settings snapshot, palette `Pattern`, mask, `Random`) and build it on the player's thread in `InteractListener`; verify with a unit test that building it needs no world access (`StrokeContextFactoryTest` verifies the mocked `World` is never touched; the listener calls `StrokeContextFactory.capture` before `runAction`) +- [x] 1.2 Add an in-memory test `Extent` and verify it round-trips `setBlock`/`getBlock` (`MemoryExtent`, `MemoryExtentTest`; block types are Mockito mocks because WorldEdit's registry cannot initialise offline, see `TestBlocks`) + +## 2. Geometry on extents + +- [x] 2.1 Port `Sphere`, `Height`, `Surface`, `ConnectedBlocks` to `Extent` + `BlockVector3`; verify each with unit tests against the in-memory extent (`SphereTest`, `HeightTest`, `SurfaceTest`, `ConnectedBlocksTest`) + +## 3. Brushes + +- [x] 3.1 Make the brush base implement WorldEdit's `Brush` and port Sphere, Spray, Splatter, Disc and Bucket brushes; verify each with a deterministic test (fixed seed, expected positions) (`ShapeBrushesTest`; the base exposes WorldEdit's `Brush` through `Brush#bind`, see design.md) +- [x] 3.2 Port Angle, Overlay, Underlay, Fracture, Gradient and Paint brushes; verify each with a deterministic test (`TerrainBrushesTest`, `StrokeRulesTest#everyBrushIsDeterministic`) +- [x] 3.3 Remove every Bukkit world/block call from brush code; verify `grep -rn "getBlock()\|getBlockAt\|org.bukkit.block.Block" src/main/java/net/onelitefeather/bettergopaint/objects src/main/java/net/onelitefeather/bettergopaint/utils` returns nothing (grep is empty; `NoBukkitWorldAccessTest` repeats it in the build) +- [x] 3.4 Run strokes through the session's `EditSession` so the global mask and history apply; verify with a test that a mask on the session blocks changes (`StrokeRunner` uses `LocalSession#createEditSession` and `remember`; `StrokeRulesTest#sessionMaskBlocksChanges` tests the masking wrapper against the in-memory extent, a real `EditSession` needs a server, so history and `//gmask` still need the manual check) + +## 4. Docs + +- [x] 4.1 Update docs/explanation/how-a-brush-stroke-is-applied.md (stroke context, WorldEdit session, `//gmask`) (also docs/development/testing.md and docs/reference/brushes.md) + +## Manual follow-up (needs a Minecraft client) + +- On `run-26.2`, paint with every brush, compare with 2.0 behaviour, `//undo` once, and try `//gmask`. diff --git a/openspec/changes/archive/2026-10-09-non-blocking-update-check/.openspec.yaml b/openspec/changes/archive/2026-10-09-non-blocking-update-check/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-non-blocking-update-check/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-09-non-blocking-update-check/proposal.md b/openspec/changes/archive/2026-10-09-non-blocking-update-check/proposal.md new file mode 100644 index 00000000..2e0f49c2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-non-blocking-update-check/proposal.md @@ -0,0 +1,31 @@ +# Proposal + +## Why + +On enable, `UpdateService.run()` is called directly on the main thread, so server start waits for +an HTTP request to Hangar; a slow or unreachable Hangar delays or stalls startup. The console +notice is printed right after that call and is skipped whenever the timer task runs first. A +parse error in the response is not caught. The check cannot be turned off. + +## What Changes + +- The first check runs asynchronously; the console notice is printed when the result arrives. +- HTTP requests have a connect and request timeout; failures and unparsable responses are logged + once at debug level instead of as errors with stack traces. +- New config key `generic.check-for-updates` (default `true`). +- Players with `bettergopaint.notify.admin.update` are notified once per join, not on every + three-hour check. + +Out of scope: automatic downloads. + +## Capabilities + +### New Capabilities +- `update-notification`: How BetterGoPaint learns about new releases and who is told about them. + +### Modified Capabilities + +## Impact + +- `service/UpdateService.java`, `listeners/ConnectListener.java`, `objects/other/Settings.java`, + docs/reference/configuration.md. diff --git a/openspec/changes/archive/2026-10-09-non-blocking-update-check/specs/update-notification/spec.md b/openspec/changes/archive/2026-10-09-non-blocking-update-check/specs/update-notification/spec.md new file mode 100644 index 00000000..6b4fefc6 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-non-blocking-update-check/specs/update-notification/spec.md @@ -0,0 +1,37 @@ +# Spec Delta + +## Purpose + +Defines how BetterGoPaint checks Hangar for a newer release and who is informed, without affecting +server startup or gameplay. + +## ADDED Requirements + +### Requirement: Startup is never blocked by the update check +The update check SHALL run off the main thread and SHALL NOT delay plugin enable. + +#### Scenario: Hangar unreachable +- **WHEN** Hangar does not answer during server start +- **THEN** BetterGoPaint enables without waiting for the request + +### Requirement: Update check can be disabled +The plugin SHALL NOT contact Hangar when `generic.check-for-updates` is `false`. + +#### Scenario: Disabled +- **WHEN** `generic.check-for-updates` is `false` and the server starts +- **THEN** no request to Hangar is made + +### Requirement: Admins are told about newer versions +When a newer release exists, the console SHALL be told once per check result and players with +`bettergopaint.notify.admin.update` SHALL be told once when they join. + +#### Scenario: Admin joins +- **WHEN** a newer release is known and a player with the permission joins +- **THEN** the player receives one message with the installed and the latest version + +### Requirement: Failures are quiet +A failed or unparsable update check SHALL NOT log a stack trace at WARN or ERROR level. + +#### Scenario: Invalid response +- **WHEN** Hangar answers with a body that is not a version +- **THEN** the plugin logs one debug line and keeps running diff --git a/openspec/changes/archive/2026-10-09-non-blocking-update-check/tasks.md b/openspec/changes/archive/2026-10-09-non-blocking-update-check/tasks.md new file mode 100644 index 00000000..39db49e0 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-non-blocking-update-check/tasks.md @@ -0,0 +1,12 @@ +# Tasks + +## 1. Update service + +- [x] 1.1 Schedule the first check asynchronously and print the console notice from the callback; verify by `UpdateServiceTest` (the check runs from an async timer, never inside `onEnable`) and a `run-26.2` start +- [x] 1.2 Add timeouts and catch parse errors; verify with a unit test using a stubbed HTTP response +- [x] 1.3 Add `generic.check-for-updates`; `start()` returns before scheduling when it is false; document it in docs/reference/configuration.md +- [x] 1.4 Notify players only on join; verify with `ConnectListenerTest` + +## Workflow follow-up + +- Archive the change after the pull request is merged. diff --git a/openspec/changes/archive/2026-10-09-restructure-documentation/.openspec.yaml b/openspec/changes/archive/2026-10-09-restructure-documentation/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-restructure-documentation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-09-restructure-documentation/proposal.md b/openspec/changes/archive/2026-10-09-restructure-documentation/proposal.md new file mode 100644 index 00000000..94ee9be5 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-restructure-documentation/proposal.md @@ -0,0 +1,33 @@ +# Proposal + +## Why + +The only user documentation is a README with a to-do list from 2023 and links to a personal +GitHub account. Commands, permissions, config keys and the brushes themselves are documented +nowhere, so every question ends up on Discord. AntiRedstoneClock-Remastered keeps a Diátaxis +structured `docs/` folder that GitBook publishes; BetterGoPaint should match it. + +## What Changes + +- `docs/` with tutorial, how-to, reference and explanation sections, navigation in `SUMMARY.md`, + `.gitbook.yaml` for GitBook Git Sync. +- Reference pages for brushes, commands, permissions, every `config.yml` key and supported + versions. +- A first-use tutorial and how-tos for the brush item, disabled worlds, sharing a brush as an item + and adding a language. +- Developer pages: testing and how the docs are organised (which page to update for which change). +- README rewritten around requirements, documentation links and building. + +Out of scope: setting up the GitBook space and domain; translating the docs. + +## Capabilities + +### New Capabilities +- `user-documentation`: What the published documentation covers and how it is kept in sync with the plugin. + +### Modified Capabilities + +## Impact + +- `docs/**`, `.gitbook.yaml`, `README.md`. +- Contributors update reference pages when they change commands, permissions or config keys. diff --git a/openspec/changes/archive/2026-10-09-restructure-documentation/specs/user-documentation/spec.md b/openspec/changes/archive/2026-10-09-restructure-documentation/specs/user-documentation/spec.md new file mode 100644 index 00000000..86bf330c --- /dev/null +++ b/openspec/changes/archive/2026-10-09-restructure-documentation/specs/user-documentation/spec.md @@ -0,0 +1,31 @@ +# Spec Delta + +## Purpose + +Ensures server owners and builders can find every command, permission, config key, brush and +supported version in the published documentation, and that this stays true as the plugin changes. + +## ADDED Requirements + +### Requirement: Reference pages are complete +The documentation SHALL contain reference pages listing every command, every permission, every +`config.yml` key with its default, every brush and every supported Minecraft version. + +#### Scenario: Looking up a permission +- **WHEN** a server owner opens the permissions reference +- **THEN** every permission declared in `paper-plugin.yml` is listed with its default and what it grants + +### Requirement: Reference pages change with the plugin +A pull request that adds, removes or renames a command, permission, config key, brush or supported +version SHALL update the matching reference page in the same pull request. + +#### Scenario: New config key +- **WHEN** a pull request adds `generic.max-palette-size` +- **THEN** the same pull request adds the key to docs/reference/configuration.md + +### Requirement: Published navigation +Every page under `docs/` intended for users SHALL be listed in `docs/SUMMARY.md`. + +#### Scenario: Page missing from navigation +- **WHEN** a how-to page exists but is not listed in `SUMMARY.md` +- **THEN** reviewers treat the pull request as incomplete diff --git a/openspec/changes/archive/2026-10-09-restructure-documentation/tasks.md b/openspec/changes/archive/2026-10-09-restructure-documentation/tasks.md new file mode 100644 index 00000000..6ac4ce8e --- /dev/null +++ b/openspec/changes/archive/2026-10-09-restructure-documentation/tasks.md @@ -0,0 +1,24 @@ +# Tasks + +## 1. Structure + +- [x] 1.1 Create `docs/` with `tutorial/`, `how-to/`, `reference/`, `explanation/`, `development/`, `index.md`, `SUMMARY.md` and `.gitbook.yaml`; verify every user page is listed in `SUMMARY.md` + +## 2. Content + +- [x] 2.1 Write the reference pages (brushes, commands, permissions, configuration, supported versions); verify config keys against the generated `config.yml` naming (lower-case, `-` instead of `_`) +- [x] 2.2 Write the tutorial and the four how-to guides; verify each step against the GUI slot layout in `GUI.java` and `InventoryListener.java` +- [x] 2.3 Write the explanation pages (stroke flow, scope and non-goals) +- [x] 2.4 Write docs/development/documentation.md with the page-per-change table +- [x] 2.5 Rewrite README.md around requirements, docs and building + + +## Manual follow-up (maintainers, GitBook) + +Not tracked: needs access to the OneLiteFeather GitBook organisation. + +- Connect a GitBook space to `docs/` with Git Sync and link it from the README + +## Workflow follow-up + +- Archive the change after the pull request is merged. diff --git a/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/.openspec.yaml b/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/design.md b/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/design.md new file mode 100644 index 00000000..5e49e3fa --- /dev/null +++ b/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/design.md @@ -0,0 +1,60 @@ +# Design + +## Context + +See proposal.md for motivation. The reference for tooling is AntiRedstoneClock-Remastered, which +already builds with Gradle 9.8, Java 25 and Paper 26.1.2. + +## Goals / Non-Goals + +**Goals:** +- A build that works with Gradle 9 and no deprecated APIs in our own build scripts. +- One place for versions (version catalog) and one place for supported Minecraft versions. + +**Non-Goals:** +- Supporting 1.21.x and 26.x from one jar. +- Refactoring plugin code beyond what the API upgrade forces. + +## Decisions + +### Compile against Paper 26.2, not 26.3 +26.2 has stable builds; 26.3 is published only as `-beta`. Hangar rejects unknown platform versions +and server owners expect stable support. 26.3 is added once Paper marks it stable. +*Alternative:* 26.3 beta — rejected, the API may still change. + +### Java 25 bytecode, 26.x only +Paper API 26.x is compiled for Java 25, so the plugin cannot be compiled with `--release 21` against +it. A multi-version jar would need a separate 1.21 source set. 1.21 users keep 1.1.x. +*Alternative:* keep 1.21 by compiling against 1.21.11 — rejected, it blocks use of current API and +keeps two API lines alive. + +### FAWE version pinned directly +`com.intellectualsites.bom:bom-newest` 1.56 still resolves FAWE 2.15.0, which predates 26.x +support. FAWE 2.16.1 is referenced directly; `FastAsyncWorldEdit-Bukkit` is non-transitive so it +does not drag an old Paper API onto the classpath. + +### Remove `build-logic` +It only wrapped `git` calls through `Project.exec` (removed in Gradle 9) for the old snapshot +publishing. Versioning moves to Release Please (separate change), so nothing needs git at +configuration time. + +### `MiniMessageTranslationStore` +Adventure 5 removed `TranslationRegistry` and `UTF8ResourceBundleControl`. The store formats +MiniMessage directly and understands ``, which the bundles already use, so the bundles stay +unchanged. Java 9+ reads `.properties` bundles as UTF-8 by default. + +### Drop ServerLib and paperlib +ServerLib only warned on unsafe forks of old versions; paperlib's async helpers are redundant on +Paper-only plugins. Both added shading and relocation for no benefit. + +## Risks / Trade-offs + +- [1.21 servers lose updates] → 1.1.x stays downloadable; README and supported-versions page say so. +- [Shading cloud without relocation] → same as AntiRedstoneClock; a later change can relocate. +- [Modrinth project still lists old loaders on old versions] → only new versions change. + +## Migration Plan + +1. Server owner updates to Java 25 and Paper 26.1.1+, installs FAWE 2.16.1+. +2. Replace the jar; `config.yml` keys are unchanged. +3. Rollback: put the 1.1.0 jar back on a 1.21 server. diff --git a/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/proposal.md b/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/proposal.md new file mode 100644 index 00000000..2fb6ee5c --- /dev/null +++ b/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/proposal.md @@ -0,0 +1,48 @@ +# Proposal + +## Why + +BetterGoPaint 1.1.0 targets Paper 1.21.3 and is built with Gradle 8.7, a SNAPSHOT command +framework and a local `build-logic` that calls `Project.exec`, which Gradle 9 removed. The +newest stable Paper line is 26.2 (26.3 is in beta), FastAsyncWorldEdit 2.16.1 supports up to 26.3, +and Paper 26.x requires Java 25. Without this upgrade the plugin cannot be built with current +tooling and cannot be offered for current server versions. + +## What Changes + +- **BREAKING** Compile against Paper API 26.2 and Java 25; offer the plugin for Minecraft 26.1.1, + 26.1.2 and 26.2. Servers on 1.21.x stay on BetterGoPaint 1.1.x. +- **BREAKING** `api-version` becomes `26.1`. Modrinth no longer lists `bukkit` and `folia` loaders, + because the plugin ships a `paper-plugin.yml` and has never supported Folia. +- Gradle 9.8 wrapper; dependency versions in a single version catalog in `settings.gradle.kts`; + repositories declared once in `dependencyResolutionManagement`. +- Replace abandoned or renamed Gradle plugins: `com.gradleup.shadow` 9.6.1, + `de.eldoria.plugin-yml.paper` 0.9.0, `xyz.jpenilla.run-paper` 3.1.0, Hangar publish 0.1.4, + Spotless 8.10.4. +- FastAsyncWorldEdit 2.16.1 referenced directly instead of the lagging `bom-newest` (FAWE 2.15.0). +- Cloud command framework from `2.0.0-SNAPSHOT` to the released 2.0.1 / 2.1.0. +- Drop `ServerLib`, `paperlib`, `publishdata`, EldoNexus Maven publishing and the `build-logic` + included build; nothing in the plugin consumes them on Paper 26. +- Migrate translations from the removed Adventure `TranslationRegistry` to + `MiniMessageTranslationStore`. Language files in `plugins/BetterGoPaint/lang/` fall back to the + bundled English file instead of failing. +- `run-` tasks start Paper with FastAsyncWorldEdit preinstalled. + +Out of scope: Folia support, Minecraft 26.3 (added once Paper publishes a stable build), +behaviour changes to brushes, commands or the GUI. + +## Capabilities + +### New Capabilities +- `platform-compatibility`: Which servers, Java versions and companion plugins BetterGoPaint runs + on, and how it behaves when the environment is unsuitable. + +### Modified Capabilities + +## Impact + +- `build.gradle.kts`, `settings.gradle.kts`, `gradle.properties`, Gradle wrapper, removal of + `build-logic/`. +- `BetterGoPaint.java` (translations, ServerLib removal), removal of `PluginTranslationRegistry`. +- Server owners must run Java 25 and Paper 26.1.1 or newer. +- Published artifacts on Hangar and Modrinth list only 26.x versions. diff --git a/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/specs/platform-compatibility/spec.md b/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/specs/platform-compatibility/spec.md new file mode 100644 index 00000000..7f993710 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/specs/platform-compatibility/spec.md @@ -0,0 +1,56 @@ +# Spec Delta + +## Purpose + +Defines the server software, Minecraft versions, Java runtime and companion plugins BetterGoPaint +supports, and how the plugin reacts when it is started in an unsuitable environment. + +## ADDED Requirements + +### Requirement: Supported Minecraft versions +The plugin SHALL be published for, and SHALL load on, Paper for every Minecraft version in the +supported-versions list. The list SHALL contain 26.1.1, 26.1.2 and 26.2. + +#### Scenario: Release lists supported versions +- **WHEN** a release is published to Hangar and Modrinth +- **THEN** the release lists exactly the Minecraft versions of the supported-versions list + +#### Scenario: Plugin loads on a supported version +- **WHEN** the plugin jar is placed on a Paper 26.2 server running Java 25 with FastAsyncWorldEdit 2.16.1 +- **THEN** the plugin enables without errors + +### Requirement: Java runtime +The plugin SHALL require Java 25 at runtime. + +#### Scenario: Older Java is rejected by the server +- **WHEN** the plugin jar is loaded by a JVM older than Java 25 +- **THEN** the server refuses to load the plugin classes and BetterGoPaint does not enable + +### Requirement: FastAsyncWorldEdit is required +The plugin SHALL declare FastAsyncWorldEdit as a required dependency that loads before it. + +#### Scenario: FAWE missing +- **WHEN** the server starts without FastAsyncWorldEdit installed +- **THEN** Paper does not enable BetterGoPaint and reports the missing dependency + +### Requirement: Refuse to run next to goPaint +The plugin SHALL disable itself when the original goPaint plugin is installed at the same time. + +#### Scenario: Both plugins installed +- **WHEN** goPaint and BetterGoPaint are both installed +- **THEN** BetterGoPaint logs that only one of them may be used and disables itself + +### Requirement: Valid brush item +The plugin SHALL disable itself when the configured brush material is not an item. + +#### Scenario: Block-only material configured +- **WHEN** `generic.default-brush` is set to a material that is not an item and the server starts +- **THEN** the plugin logs the invalid value and a link to the material list, and disables itself + +### Requirement: Additional languages fall back to English +The plugin SHALL load extra languages from `plugins/BetterGoPaint/lang/` and SHALL fall back to +the bundled English messages for any key or locale not provided there. + +#### Scenario: Only a German file is provided +- **WHEN** `lang/` contains only `bettergopaint_de_DE.properties` and `generic.languages` lists `de-DE` +- **THEN** the plugin enables, German clients receive German messages and all other clients receive English diff --git a/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/tasks.md b/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/tasks.md new file mode 100644 index 00000000..da964428 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-upgrade-to-minecraft-26/tasks.md @@ -0,0 +1,35 @@ +# Tasks + +## 1. Build tooling + +- [x] 1.1 Update the Gradle wrapper to 9.8.0 and verify `./gradlew --version` reports 9.8.0 +- [x] 1.2 Move all versions into the version catalog in `settings.gradle.kts` and repositories into `dependencyResolutionManagement`; verify `./gradlew help` configures without warnings from our scripts +- [x] 1.3 Replace shadow, plugin-yml, run-paper, hangar and spotless plugins with their current coordinates; verify `./gradlew build` succeeds +- [x] 1.4 Remove `build-logic/`, `publishdata` and EldoNexus publishing; verify `./gradlew tasks` no longer lists `publish` + +## 2. Platform upgrade + +- [x] 2.1 Compile against Paper API 26.2 with Java 25 toolchain; verify `./gradlew compileJava` succeeds +- [x] 2.2 Reference FastAsyncWorldEdit 2.16.1 directly; verify the dependency report shows FAWE 2.16.1 +- [x] 2.3 Set `api-version` to 26.1 and list 26.1.1, 26.1.2, 26.2 as supported versions; verify the generated `paper-plugin.yml` in the jar +- [x] 2.4 Drop ServerLib and paperlib; verify the shaded jar contains no `org/incendo/serverlib` or `io/papermc/lib` +- [x] 2.5 Update docs/reference/supported-versions.md and README requirements; verify they match `supportedMinecraftVersions` + +## 3. Translations + +- [x] 3.1 Replace `PluginTranslationRegistry` with `MiniMessageTranslationStore`; verify `TranslationBundleTest` passes +- [x] 3.2 Load `lang/` bundles with the plugin class loader as parent so missing locales fall back to English; verified on `run-26.2` with only a `de_DE` file in `lang/` and `languages: [de-DE]` + +## 4. Verification + +- [x] 4.2 Start `./gradlew run-26.1.2` and verify the plugin enables + +## Manual follow-up (needs a Minecraft client) + +Not tracked: the session that implemented this change had no game client. Server start, commands and config loading were verified on run-26.2 and run-26.1.2. + +- Start `./gradlew run-26.2`, paint with every brush and `//undo` once; verify no errors in the console + +## Workflow follow-up + +- Archive the change after the pull request is merged. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 00000000..9f06a9f7 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,50 @@ +schema: spec-driven + +context: | + BetterGoPaint is a Paper plugin (paper-plugin.yml, not plugin.yml). FastAsyncWorldEdit is a hard + runtime dependency; every block change goes through a FAWE edit session. + Quality bar and conventions follow OneLiteFeatherNET/AntiRedstoneClock-Remastered: Conventional + Commits (enforced by commitlint and pr-lint), Release Please for versioning, Diátaxis docs in docs/ + published by GitBook, JUnit 6 + AssertJ + Mockito tests that run without a server. + The supported Minecraft versions are defined once in build.gradle.kts (supportedMinecraftVersions) + and mirrored in docs/reference/supported-versions.md. + Player-facing text must be translatable through the bettergopaint*.properties bundles (MiniMessage). + Write all OpenSpec artifacts in English. + +rules: + proposal: + - State what is out of scope. + - Mark changes that need a server owner to act (new Java, new Minecraft, config changes) as BREAKING. + tasks: + - Every group that changes a command, permission, config key or supported version updates the matching page under docs/reference/. + +# Project context (optional) +# Add only constraints that should guide OpenSpec artifacts and workflows. +# Include constraints an agent cannot infer by reading the code. +# Keep general project documentation and discoverable codebase facts out. +# Example: +# context: | +# Designs and tasks must cover Windows, macOS, and Linux +# Write all artifacts in Spanish + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always state what is out of scope +# tasks: +# - Break tasks into chunks of max 2 hours + +# Per-operation guidance (optional) +# Add advisory guidance for how apply and archive work should be conducted. +# This is separate from artifact rules above. +# Example: +# operations: +# apply: +# guidance: +# - Keep test summaries concise +# archive: +# guidance: +# - Summarize the archive outcome before finishing diff --git a/openspec/specs/.gitkeep b/openspec/specs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/openspec/specs/brush-menu/spec.md b/openspec/specs/brush-menu/spec.md new file mode 100644 index 00000000..6354eb33 --- /dev/null +++ b/openspec/specs/brush-menu/spec.md @@ -0,0 +1,34 @@ +# brush-menu Specification + +## Purpose +Defines how the brush menu and the brush picker are opened and identified, and that clicks are only +intercepted in those inventories. + +## Requirements + +### Requirement: Menu identity does not depend on the title +The plugin SHALL handle clicks only in inventories it created for the brush menu or brush picker, +regardless of the inventory title. + +#### Scenario: Foreign inventory with the same title +- **WHEN** another plugin opens a chest titled "goPaint Menu" and the player clicks in it +- **THEN** BetterGoPaint does not cancel or handle the click + +#### Scenario: Translated title +- **WHEN** the menu title is translated into German +- **THEN** clicks in the menu still change the brush settings + +### Requirement: Player inventory stays usable +While the brush menu is open, clicks in the player's own inventory SHALL only be cancelled for +shift-clicks and collect-to-cursor, which would move items into the menu. + +#### Scenario: Normal click in own inventory +- **WHEN** the brush menu is open and the player left-clicks an item in their own inventory +- **THEN** the item is picked up normally + +### Requirement: Translated menu text +Menu titles, item names and lore SHALL be resolved from the translation bundles. + +#### Scenario: German client +- **WHEN** a German client opens the brush menu and a German bundle is installed +- **THEN** titles and item texts are German diff --git a/openspec/specs/brush-painting/spec.md b/openspec/specs/brush-painting/spec.md new file mode 100644 index 00000000..5edb8bbb --- /dev/null +++ b/openspec/specs/brush-painting/spec.md @@ -0,0 +1,45 @@ +# brush-painting Specification + +## Purpose +Defines how a brush stroke reads and modifies the world. Every stroke goes through the builder's +WorldEdit session, so it is thread-safe, undoable and limited like any other WorldEdit edit. + +## Requirements + +### Requirement: World access only through WorldEdit +A brush stroke SHALL read and write blocks only through the WorldEdit edit session of the stroke, +and SHALL NOT call Bukkit world, block or location methods while the stroke is computed. + +#### Scenario: Stroke computed in FAWE's queue +- **WHEN** a builder right-clicks with a brush and FAWE runs the stroke on a worker thread +- **THEN** no Bukkit world or block method is called on that thread + +### Requirement: One stroke, one undo step +Each brush stroke SHALL be recorded in the builder's WorldEdit history as exactly one entry. + +#### Scenario: Undo a stroke +- **WHEN** a builder paints one stroke and runs `//undo` +- **THEN** every block changed by that stroke is restored and nothing else + +### Requirement: WorldEdit global mask is respected +A brush stroke SHALL NOT change blocks that the builder's WorldEdit global mask excludes. + +#### Scenario: Global mask set +- **WHEN** a builder ran `//gmask grass_block` and paints with a sphere brush over grass and stone +- **THEN** only grass blocks are changed + +### Requirement: Palette and mask behave as before +A stroke SHALL place only blocks from the palette, chosen with equal weight, and with the +BetterGoPaint mask enabled it SHALL change only blocks of the mask type. + +#### Scenario: Mask enabled +- **WHEN** the palette is stone and andesite, the mask is grass block and a sphere brush hits grass and dirt +- **THEN** only grass blocks become stone or andesite + +### Requirement: Brush results are independent of the thread +For the same world, settings and random seed, a brush SHALL change the same blocks regardless of +which thread computes the stroke. + +#### Scenario: Deterministic test +- **WHEN** a brush runs twice against identical in-memory extents with the same seed +- **THEN** both runs change exactly the same positions to the same blocks diff --git a/openspec/specs/build-verification/spec.md b/openspec/specs/build-verification/spec.md new file mode 100644 index 00000000..9e5ac5a8 --- /dev/null +++ b/openspec/specs/build-verification/spec.md @@ -0,0 +1,36 @@ +# build-verification Specification + +## Purpose +Defines the automated checks that every BetterGoPaint build runs, so regressions in brush logic, +translations and source hygiene are caught before a merge or release. + +## Requirements + +### Requirement: Tests run with every build +The build SHALL run all unit tests, and a failing test SHALL fail the build. + +#### Scenario: Broken brush cycling +- **WHEN** cycling forward from the last brush no longer returns the first brush +- **THEN** `./gradlew build` fails and names the failing test + +### Requirement: Coverage report +Every test run SHALL produce a JaCoCo coverage report in XML and HTML. + +#### Scenario: Report after tests +- **WHEN** `./gradlew test` finishes +- **THEN** `build/reports/jacoco/test/jacocoTestReport.xml` exists + +### Requirement: Translation keys are complete +Every translation key used in the source SHALL exist in every shipped language bundle, and every +message SHALL parse as MiniMessage. + +#### Scenario: Key missing from a bundle +- **WHEN** code references `bettergopaint.notify.new` and a bundle lacks that key +- **THEN** the build fails and names the bundle and key + +### Requirement: License headers +Every Java source file SHALL carry the GPL license header from `HEADER.txt`. + +#### Scenario: New file without header +- **WHEN** a Java file without the header is added +- **THEN** `./gradlew build` fails in `spotlessJavaCheck` and `./gradlew spotlessApply` adds the header diff --git a/openspec/specs/commands/spec.md b/openspec/specs/commands/spec.md new file mode 100644 index 00000000..ef38aeab --- /dev/null +++ b/openspec/specs/commands/spec.md @@ -0,0 +1,41 @@ +# commands Specification + +## Purpose +Defines the `/gopaint` command tree: which subcommands exist, which arguments and permissions they +take, and what feedback players receive. + +## Requirements + +### Requirement: Single command tree +The plugin SHALL register exactly one command `/gopaint` with the aliases `/gp` and `/bgp`. + +#### Scenario: Alias resolves +- **WHEN** a player runs `/bgp toggle` +- **THEN** the same handler as `/gopaint toggle` runs + +### Requirement: Brush size argument +`/gopaint size ` SHALL accept integers from 1 to `generic.max-size` and SHALL reject other +input with a translated error that names the allowed range. + +#### Scenario: Too large +- **WHEN** `generic.max-size` is 100 and a player runs `/gopaint size 500` +- **THEN** the size is unchanged and the player is told the allowed range is 1–100 + +#### Scenario: Tab completion +- **WHEN** a player types `/gopaint size ` and presses tab +- **THEN** suggestions within the allowed range are offered + +### Requirement: Permission per subcommand +Each subcommand SHALL require its own permission in addition to `bettergopaint.use`, and the +generated help SHALL only list subcommands the sender may run. + +#### Scenario: Player without reload permission +- **WHEN** a player without `bettergopaint.command.admin.reload` runs `/gopaint help` +- **THEN** `reload` is not listed + +### Requirement: Translated feedback +Every message sent by a command SHALL be resolved from the translation bundles in the player's locale. + +#### Scenario: German client +- **WHEN** a player with a German client and a German bundle installed runs `/gopaint toggle` +- **THEN** the confirmation is shown in German diff --git a/openspec/specs/exported-brushes/spec.md b/openspec/specs/exported-brushes/spec.md new file mode 100644 index 00000000..3d07c635 --- /dev/null +++ b/openspec/specs/exported-brushes/spec.md @@ -0,0 +1,31 @@ +# exported-brushes Specification + +## Purpose +Defines how a brush configuration is stored on an item, how such an item paints, and how it behaves +when the stored data is missing or invalid. + +## Requirements + +### Requirement: Brush stored as item data +Exporting a brush onto an item SHALL store the brush type, size, palette and every brush setting as +structured item data, independent of the item's name and lore. + +#### Scenario: Renamed item keeps working +- **WHEN** a player exports a brush onto a stick and renames the stick in an anvil +- **THEN** right-clicking with the stick paints with the exported settings + +### Requirement: Invalid data never breaks painting +An item with missing or unreadable brush data SHALL be treated as a normal item, and invalid single +values SHALL fall back to the configured defaults. + +#### Scenario: Edited lore +- **WHEN** a legacy exported item's lore line `Size: 10` was edited to `Size: ten` +- **THEN** right-clicking paints with the default size and no exception is logged + +### Requirement: Legacy items are converted +Items exported by BetterGoPaint 1.x SHALL keep working and SHALL be converted to the new format the +first time they are used. + +#### Scenario: Item from 1.1.0 +- **WHEN** a player right-clicks with a brush item exported by 1.1.0 +- **THEN** it paints with the stored settings and afterwards carries the new item data diff --git a/openspec/specs/platform-compatibility/spec.md b/openspec/specs/platform-compatibility/spec.md new file mode 100644 index 00000000..9f04e6c8 --- /dev/null +++ b/openspec/specs/platform-compatibility/spec.md @@ -0,0 +1,55 @@ +# platform-compatibility Specification + +## Purpose +Defines the server software, Minecraft versions, Java runtime and companion plugins BetterGoPaint +supports, and how the plugin reacts when it is started in an unsuitable environment. + +## Requirements + +### Requirement: Supported Minecraft versions +The plugin SHALL be published for, and SHALL load on, Paper for every Minecraft version in the +supported-versions list. The list SHALL contain 26.1.1, 26.1.2 and 26.2. + +#### Scenario: Release lists supported versions +- **WHEN** a release is published to Hangar and Modrinth +- **THEN** the release lists exactly the Minecraft versions of the supported-versions list + +#### Scenario: Plugin loads on a supported version +- **WHEN** the plugin jar is placed on a Paper 26.2 server running Java 25 with FastAsyncWorldEdit 2.16.1 +- **THEN** the plugin enables without errors + +### Requirement: Java runtime +The plugin SHALL require Java 25 at runtime. + +#### Scenario: Older Java is rejected by the server +- **WHEN** the plugin jar is loaded by a JVM older than Java 25 +- **THEN** the server refuses to load the plugin classes and BetterGoPaint does not enable + +### Requirement: FastAsyncWorldEdit is required +The plugin SHALL declare FastAsyncWorldEdit as a required dependency that loads before it. + +#### Scenario: FAWE missing +- **WHEN** the server starts without FastAsyncWorldEdit installed +- **THEN** Paper does not enable BetterGoPaint and reports the missing dependency + +### Requirement: Refuse to run next to goPaint +The plugin SHALL disable itself when the original goPaint plugin is installed at the same time. + +#### Scenario: Both plugins installed +- **WHEN** goPaint and BetterGoPaint are both installed +- **THEN** BetterGoPaint logs that only one of them may be used and disables itself + +### Requirement: Valid brush item +The plugin SHALL disable itself when the configured brush material is not an item. + +#### Scenario: Block-only material configured +- **WHEN** `generic.default-brush` is set to a material that is not an item and the server starts +- **THEN** the plugin logs the invalid value and a link to the material list, and disables itself + +### Requirement: Additional languages fall back to English +The plugin SHALL load extra languages from `plugins/BetterGoPaint/lang/` and SHALL fall back to +the bundled English messages for any key or locale not provided there. + +#### Scenario: Only a German file is provided +- **WHEN** `lang/` contains only `bettergopaint_de_DE.properties` and `generic.languages` lists `de-DE` +- **THEN** the plugin enables, German clients receive German messages and all other clients receive English diff --git a/openspec/specs/release-pipeline/spec.md b/openspec/specs/release-pipeline/spec.md new file mode 100644 index 00000000..b0149ffc --- /dev/null +++ b/openspec/specs/release-pipeline/spec.md @@ -0,0 +1,58 @@ +# release-pipeline Specification + +## Purpose +Describes how changes to BetterGoPaint turn into a versioned release, which checks gate it and +where it is published. + +## Requirements + +### Requirement: Conventional commit messages +Every pull request title and every commit in a pull request SHALL follow Conventional Commits with +one of the types build, chore, ci, docs, feat, fix, perf, refactor, revert, style or test. + +#### Scenario: Non-conventional title +- **WHEN** a pull request is opened with the title "Update stuff" +- **THEN** the pr-lint check fails and names the expected format + +#### Scenario: Conventional title +- **WHEN** a pull request is opened with the title "fix: keep the gradient brush inside the mask" +- **THEN** the pr-lint title check passes + +### Requirement: Version derived from commits +The next version SHALL be derived from the commits since the last release: a breaking change bumps +MAJOR, `feat` bumps MINOR, `fix` bumps PATCH, other types do not release. + +#### Scenario: Feature merged +- **WHEN** the last release is 1.1.0 and a `feat:` commit is merged into main +- **THEN** the release pull request targets 1.2.0 + +### Requirement: Releases happen only by merging the release pull request +Pushing to main SHALL NOT publish anything. Merging the release pull request SHALL tag the version, +update the changelog and start publishing. + +#### Scenario: Ordinary merge +- **WHEN** a feature pull request is merged into main +- **THEN** no artifact is uploaded to Hangar or Modrinth and the release pull request is updated + +### Requirement: Security gate before publishing +The built jar SHALL be scanned before any public upload, and a HIGH or CRITICAL finding SHALL stop +the release. + +#### Scenario: Vulnerable dependency shaded +- **WHEN** the release jar contains a dependency with a CRITICAL CVE +- **THEN** the publish job does not run and nothing is uploaded + +### Requirement: Published artifacts +A successful release SHALL attach the shaded jar to the GitHub release and publish the same jar to +Hangar and Modrinth for every supported Minecraft version, and SHALL upload a CycloneDX SBOM. + +#### Scenario: Hangar rejects the upload +- **WHEN** Hangar rejects the upload +- **THEN** the jar is still attached to the GitHub release and the tag can be re-published with workflow_dispatch + +### Requirement: Pull request builds +Every pull request SHALL be built and tested on Linux, Windows and macOS. + +#### Scenario: Test fails on one OS +- **WHEN** a test fails on Windows only +- **THEN** the pull request build is red diff --git a/openspec/specs/server-threading/spec.md b/openspec/specs/server-threading/spec.md new file mode 100644 index 00000000..72f42e17 --- /dev/null +++ b/openspec/specs/server-threading/spec.md @@ -0,0 +1,34 @@ +# server-threading Specification + +## Purpose +Defines how BetterGoPaint schedules work and shares state across threads, so that it runs on Paper +today and on Folia as soon as its required dependencies do. + +## Requirements + +### Requirement: Region-aware scheduling only +The plugin SHALL schedule work only through Paper's async, global region and entity schedulers, and +SHALL NOT use the legacy `BukkitScheduler`. + +#### Scenario: Update check on Folia +- **WHEN** the plugin starts on a Folia server with update checks enabled +- **THEN** the update check is scheduled without an `UnsupportedOperationException` + +### Requirement: Thread-safe per-player state +Per-player brush state SHALL be safe to read and write from different threads at the same time. + +#### Scenario: Two players paint in different regions +- **WHEN** two builders in different Folia regions paint and change brush settings at the same time +- **THEN** each keeps their own settings and no state is lost or corrupted + +### Requirement: Folia declared only with a Folia-capable FAWE +The published plugin SHALL NOT declare Folia support while no stable FastAsyncWorldEdit release +supports Folia. Declaring it SHALL require changing only one build property. + +#### Scenario: Default build +- **WHEN** the plugin is built without extra properties +- **THEN** the generated `paper-plugin.yml` does not declare `folia-supported: true` + +#### Scenario: Opt-in test build +- **WHEN** the plugin is built with `-PfoliaSupported=true` +- **THEN** the generated `paper-plugin.yml` declares `folia-supported: true` diff --git a/openspec/specs/update-notification/spec.md b/openspec/specs/update-notification/spec.md new file mode 100644 index 00000000..c1548001 --- /dev/null +++ b/openspec/specs/update-notification/spec.md @@ -0,0 +1,36 @@ +# update-notification Specification + +## Purpose +Defines how BetterGoPaint checks Hangar for a newer release and who is informed, without affecting +server startup or gameplay. + +## Requirements + +### Requirement: Startup is never blocked by the update check +The update check SHALL run off the main thread and SHALL NOT delay plugin enable. + +#### Scenario: Hangar unreachable +- **WHEN** Hangar does not answer during server start +- **THEN** BetterGoPaint enables without waiting for the request + +### Requirement: Update check can be disabled +The plugin SHALL NOT contact Hangar when `generic.check-for-updates` is `false`. + +#### Scenario: Disabled +- **WHEN** `generic.check-for-updates` is `false` and the server starts +- **THEN** no request to Hangar is made + +### Requirement: Admins are told about newer versions +When a newer release exists, the console SHALL be told once per check result and players with +`bettergopaint.notify.admin.update` SHALL be told once when they join. + +#### Scenario: Admin joins +- **WHEN** a newer release is known and a player with the permission joins +- **THEN** the player receives one message with the installed and the latest version + +### Requirement: Failures are quiet +A failed or unparsable update check SHALL NOT log a stack trace at WARN or ERROR level. + +#### Scenario: Invalid response +- **WHEN** Hangar answers with a body that is not a version +- **THEN** the plugin logs one debug line and keeps running diff --git a/openspec/specs/user-documentation/spec.md b/openspec/specs/user-documentation/spec.md new file mode 100644 index 00000000..e072e888 --- /dev/null +++ b/openspec/specs/user-documentation/spec.md @@ -0,0 +1,30 @@ +# user-documentation Specification + +## Purpose +Ensures server owners and builders can find every command, permission, config key, brush and +supported version in the published documentation, and that this stays true as the plugin changes. + +## Requirements + +### Requirement: Reference pages are complete +The documentation SHALL contain reference pages listing every command, every permission, every +`config.yml` key with its default, every brush and every supported Minecraft version. + +#### Scenario: Looking up a permission +- **WHEN** a server owner opens the permissions reference +- **THEN** every permission declared in `paper-plugin.yml` is listed with its default and what it grants + +### Requirement: Reference pages change with the plugin +A pull request that adds, removes or renames a command, permission, config key, brush or supported +version SHALL update the matching reference page in the same pull request. + +#### Scenario: New config key +- **WHEN** a pull request adds `generic.max-palette-size` +- **THEN** the same pull request adds the key to docs/reference/configuration.md + +### Requirement: Published navigation +Every page under `docs/` intended for users SHALL be listed in `docs/SUMMARY.md`. + +#### Scenario: Page missing from navigation +- **WHEN** a how-to page exists but is not listed in `SUMMARY.md` +- **THEN** reviewers treat the pull request as incomplete diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..5a247e22 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-component-in-tag": false, + "include-v-in-tag": true, + "bootstrap-sha": "048f4e343770125162f5a696a77a1d3fa2d95a30", + "pull-request-header": "", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "refactor", "section": "Code Refactoring" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true } + ], + "packages": { + ".": { + "package-name": "BetterGoPaint", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { "type": "generic", "path": "build.gradle.kts" } + ] + } + } +} diff --git a/renovate.json b/renovate.json index 1e5efd3e..1bd0ca37 100644 --- a/renovate.json +++ b/renovate.json @@ -1,11 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:base", - ":semanticCommitsDisabled" - ], - "labels": ["Renovate"], - "rebaseWhen": "conflicted", - "schedule": ["on the first day of the month"], - "automerge": true + "github>OneLiteFeatherNET/renovate:default(OneLiteFeatherNET/bettergopaint-core)", + "github>OneLiteFeatherNET/renovate:paper" + ] } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9f70ff0f..e603d4b2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,54 +1,66 @@ rootProject.name = "BetterGoPaint" -includeBuild("build-logic") -pluginManagement { +dependencyResolutionManagement { repositories { - maven("https://eldonexus.de/repository/maven-public/") - gradlePluginPortal() + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") + maven("https://maven.enginehub.org/repo/") } -} - -dependencyResolutionManagement { versionCatalogs { create("libs") { - version("publishdata", "1.4.0") version("modrinth", "2.+") - version("hangar", "0.1.2") - version("paper.yml", "0.6.0") - version("paper.run", "2.3.0") - version("shadowJar", "8.1.1") - version("intellectualsites", "1.51") + version("hangar", "0.1.4") + version("paper.yml", "0.9.0") + version("paper.run", "3.1.0") + version("shadowJar", "9.6.1") + version("cyclonedx", "3.4.1") + version("spotless", "8.10.4") - version("paper", "1.21.3-R0.1-SNAPSHOT") - version("bstats", "3.0.2") + // Compile against the newest stable Paper line. Paper 26.x requires Java 25. + version("paper", "26.2.build.+") + version("fawe", "2.16.1") + version("bstats", "3.2.1") - version("cloudcommand", "2.0.0-SNAPSHOT") + version("cloudcommandPaper", "2.0.1") + version("cloudcommandAnnotations", "2.1.0") + version("cloudcommandExtras", "2.0.1") - version("adventure", "4.17.0") version("semver", "0.10.2") + version("guice", "7.0.0") + version("jakarta-inject", "2.0.1") + + // Testing dependencies + version("junit", "6.1.3") + version("mockito", "5.24.0") + version("assertj", "3.27.7") library("paper", "io.papermc.paper", "paper-api").versionRef("paper") - library("minimessage", "net.kyori", "adventure-text-minimessage").versionRef("adventure") + library("fawe.core", "com.fastasyncworldedit", "FastAsyncWorldEdit-Core").versionRef("fawe") + library("fawe.bukkit", "com.fastasyncworldedit", "FastAsyncWorldEdit-Bukkit").versionRef("fawe") library("bstats", "org.bstats", "bstats-bukkit").versionRef("bstats") - library("fawe.bom", "com.intellectualsites.bom", "bom-newest").versionRef("intellectualsites") - library("fawe.bukkit", "com.fastasyncworldedit", "FastAsyncWorldEdit-Bukkit").withoutVersion() - library("serverlib", "dev.notmyfault.serverlib", "ServerLib").withoutVersion() - library("paperlib", "io.papermc", "paperlib").withoutVersion() - - library("cloud.command.paper", "org.incendo", "cloud-paper").versionRef("cloudcommand") - library("cloud.command.annotations", "org.incendo", "cloud-annotations").versionRef("cloudcommand") - library("cloud.command.extras", "org.incendo", "cloud-minecraft-extras").versionRef("cloudcommand") + library("cloud.command.paper", "org.incendo", "cloud-paper").versionRef("cloudcommandPaper") + library("cloud.command.annotations", "org.incendo", "cloud-annotations").versionRef("cloudcommandAnnotations") + library("cloud.command.extras", "org.incendo", "cloud-minecraft-extras").versionRef("cloudcommandExtras") library("semver", "com.github.zafarkhaja", "java-semver").versionRef("semver") + library("guice", "com.google.inject", "guice").versionRef("guice") + library("jakarta-inject", "jakarta.inject", "jakarta.inject-api").versionRef("jakarta-inject") + + // Testing libraries + library("junit-jupiter", "org.junit.jupiter", "junit-jupiter").versionRef("junit") + library("junit-platform-launcher", "org.junit.platform", "junit-platform-launcher").withoutVersion() + library("mockito-core", "org.mockito", "mockito-core").versionRef("mockito") + library("mockito-junit-jupiter", "org.mockito", "mockito-junit-jupiter").versionRef("mockito") + library("assertj-core", "org.assertj", "assertj-core").versionRef("assertj") - plugin("publishdata","de.chojo.publishdata").versionRef("publishdata") plugin("modrinth", "com.modrinth.minotaur").versionRef("modrinth") plugin("hangar", "io.papermc.hangar-publish-plugin").versionRef("hangar") - plugin("paper.yml", "net.minecrell.plugin-yml.paper").versionRef("paper.yml") + plugin("paper.yml", "de.eldoria.plugin-yml.paper").versionRef("paper.yml") plugin("paper.run", "xyz.jpenilla.run-paper").versionRef("paper.run") - plugin("shadowJar", "com.github.johnrengelman.shadow").versionRef("shadowJar") - plugin("spotless", "com.diffplug.spotless").version("6.18.0") + plugin("shadowJar", "com.gradleup.shadow").versionRef("shadowJar") + plugin("cyclonedx", "org.cyclonedx.bom").versionRef("cyclonedx") + plugin("spotless", "com.diffplug.spotless").versionRef("spotless") } } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/BetterGoPaint.java b/src/main/java/net/onelitefeather/bettergopaint/BetterGoPaint.java index 0a7f5df4..42d342b0 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/BetterGoPaint.java +++ b/src/main/java/net/onelitefeather/bettergopaint/BetterGoPaint.java @@ -18,71 +18,44 @@ */ package net.onelitefeather.bettergopaint; -import com.fastasyncworldedit.core.Fawe; -import net.kyori.adventure.key.Key; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.Stage; +import com.google.inject.TypeLiteral; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.translation.GlobalTranslator; -import net.kyori.adventure.translation.TranslationRegistry; -import net.kyori.adventure.util.UTF8ResourceBundleControl; -import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; import net.onelitefeather.bettergopaint.command.GoPaintCommand; -import net.onelitefeather.bettergopaint.command.ReloadCommand; -import net.onelitefeather.bettergopaint.listeners.ConnectListener; -import net.onelitefeather.bettergopaint.listeners.InteractListener; -import net.onelitefeather.bettergopaint.listeners.InventoryListener; -import net.onelitefeather.bettergopaint.objects.other.Settings; +import net.onelitefeather.bettergopaint.injection.BrushModule; +import net.onelitefeather.bettergopaint.injection.CommandModule; +import net.onelitefeather.bettergopaint.injection.ListenerModule; +import net.onelitefeather.bettergopaint.injection.PlatformModule; +import net.onelitefeather.bettergopaint.injection.ServiceModule; +import net.onelitefeather.bettergopaint.injection.SettingsModule; +import net.onelitefeather.bettergopaint.service.MetricsService; +import net.onelitefeather.bettergopaint.service.TranslationService; import net.onelitefeather.bettergopaint.service.UpdateService; -import net.onelitefeather.bettergopaint.translations.PluginTranslationRegistry; -import org.bstats.bukkit.Metrics; -import org.bstats.charts.SimplePie; -import org.bukkit.Bukkit; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; import org.bukkit.Material; -import org.bukkit.command.CommandSender; import org.bukkit.event.Listener; -import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; +import org.incendo.cloud.CommandManager; import org.incendo.cloud.annotations.AnnotationParser; -import org.incendo.cloud.bukkit.CloudBukkitCapabilities; -import org.incendo.cloud.execution.ExecutionCoordinator; -import org.incendo.cloud.paper.LegacyPaperCommandManager; -import org.incendo.serverlib.ServerLib; -import org.jetbrains.annotations.NotNull; +import org.incendo.cloud.paper.util.sender.Source; import org.jetbrains.annotations.Nullable; -import java.io.File; -import java.io.IOException; -import java.net.URL; -import java.net.URLClassLoader; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashSet; -import java.util.Locale; -import java.util.Objects; -import java.util.ResourceBundle; -import java.util.logging.Level; +import java.util.Set; -public class BetterGoPaint extends JavaPlugin implements Listener { - - public static final String PAPER_DOCS = "https://jd.papermc.io/paper/1.20.6/org/bukkit/Material.html#enum-constant-summary"; +/** + * Entry point. Everything else is created by Guice; this class only decides the order things start in. + */ +public final class BetterGoPaint extends JavaPlugin { - private final PlayerBrushManager brushManager = new PlayerBrushManager(); - private final Metrics metrics = new Metrics(this, 18734); - private UpdateService updateService; - - @Override - public void onLoad() { - metrics.addCustomChart(new SimplePie( - "faweVersion", - () -> Objects.requireNonNull(Fawe.instance().getVersion()).toString() - )); - } + private @Nullable Injector injector; @Override public void onEnable() { - // Check if we are in a safe environment - ServerLib.checkUnsafeForks(); - // disable if goPaint and BetterGoPaint are installed simultaneously if (hasOriginalGoPaint()) { getComponentLogger().error("BetterGoPaint is a replacement for goPaint. Please use one instead of both"); @@ -91,124 +64,53 @@ public void onEnable() { return; } - reloadConfig(); - - final TranslationRegistry translationRegistry = new PluginTranslationRegistry(TranslationRegistry.create(Key.key("bettergopaint", "translations"))); - translationRegistry.defaultLocale(Locale.US); - Path langFolder = getDataFolder().toPath().resolve("lang"); - var languages = new HashSet<>(Settings.settings().generic.LANGUAGES); - languages.add("en-US"); - if (Files.exists(langFolder)) { - try (var urlClassLoader = new URLClassLoader(new URL[]{langFolder.toUri().toURL()})) { - languages.stream().map(Locale::forLanguageTag).forEach(r -> { - var bundle = ResourceBundle.getBundle("bettergopaint", r, urlClassLoader, UTF8ResourceBundleControl.get()); - translationRegistry.registerAll(r, bundle, false); - }); - } catch (IOException e) { - throw new RuntimeException(e); - } - } else { - languages.stream().map(Locale::forLanguageTag).forEach(r -> { - var bundle = ResourceBundle.getBundle("bettergopaint", r, UTF8ResourceBundleControl.get()); - translationRegistry.registerAll(r, bundle, false); - }); - } - GlobalTranslator.translator().addSource(translationRegistry); - donationInformation(); - + injector = Guice.createInjector(Stage.PRODUCTION, + new PlatformModule(this), + new SettingsModule(getDataFolder().toPath()), + new BrushModule(), + new ServiceModule(), + new ListenerModule(), + new CommandModule() + ); - Material brush = Settings.settings().generic.DEFAULT_BRUSH; + Material brush = injector.getInstance(SettingsHolder.class).get().generic().defaultBrush(); if (!brush.isItem()) { getComponentLogger().error("{} is not a valid default brush, it has to be an item", brush.name()); - getComponentLogger().error("For more information visit {}", PAPER_DOCS); + getComponentLogger().error("For more information visit {}", Settings.PAPER_MATERIALS); getServer().getPluginManager().disablePlugin(this); + return; } + injector.getInstance(TranslationService.class).register(); + getComponentLogger().info(Component.translatable("bettergopaint.notify.donation.console")); //noinspection UnnecessaryUnicodeEscape getComponentLogger().info(MiniMessage.miniMessage().deserialize( - "Made with \u2665 in Germany" + "Made with ♥ in Germany" )); - registerListeners(); - registerCommands(); - updateService(); + injector.getInstance(MetricsService.class).start(); + injector.getInstance(Key.get(new TypeLiteral>() { + })).forEach(listener -> getServer().getPluginManager().registerEvents(listener, this)); + registerCommands(injector); + injector.getInstance(UpdateService.class).start(); } @Override public void onDisable() { - metrics.shutdown(); - this.updateService.shutdown(); - } - - public void reloadConfig() { - try { - Files.createDirectories(getDataFolder().toPath()); - final Path resolve = getDataFolder().toPath().resolve("config.yml"); - Settings.settings().save(resolve.toFile()); - Settings.settings().load(resolve.toFile()); - } catch (IOException e) { - getLogger().log(Level.SEVERE, "Cannot init config", e); - } - - } - - private void updateService() { - this.updateService = new UpdateService(this); - this.updateService.run(); - this.updateService.notifyConsole(getComponentLogger()); - } - - - @SuppressWarnings("UnstableApiUsage") - private void registerCommands() { - Bukkit.getCommandMap().register("gopaint", getPluginMeta().getName(), new GoPaintCommand(this)); - - var annotationParser = enableCommandSystem(); - if (annotationParser != null) { - annotationParser.parse(new ReloadCommand(this)); - annotationParser.parse(new GoPaintCommand(this)); + if (injector == null) { + return; } + injector.getInstance(UpdateService.class).shutdown(); + injector.getInstance(MetricsService.class).shutdown(); } - private void registerListeners() { - PluginManager pm = getServer().getPluginManager(); - pm.registerEvents(new InventoryListener(getBrushManager()), this); - pm.registerEvents(new InteractListener(this), this); - pm.registerEvents(new ConnectListener(getBrushManager(), this), this); + private void registerCommands(Injector injector) { + CommandManager commandManager = injector.getInstance(Key.get(new TypeLiteral>() { + })); + new AnnotationParser<>(commandManager, Source.class).parse(injector.getInstance(GoPaintCommand.class)); } private boolean hasOriginalGoPaint() { return getServer().getPluginManager().getPlugin("goPaint") != this; } - - private void donationInformation() { - getComponentLogger().info(Component.translatable("bettergopaint.notify.donation.console")); - } - - private @Nullable AnnotationParser enableCommandSystem() { - try { - LegacyPaperCommandManager commandManager = LegacyPaperCommandManager.createNative( - this, - ExecutionCoordinator.simpleCoordinator() - ); - if (commandManager.hasCapability(CloudBukkitCapabilities.BRIGADIER)) { - commandManager.registerBrigadier(); - getLogger().info("Brigadier support enabled"); - } - return new AnnotationParser<>(commandManager, CommandSender.class); - - } catch (Exception exception) { - getLogger().log(Level.SEVERE, "Cannot init command manager", exception); - return null; - } - } - - public @NotNull PlayerBrushManager getBrushManager() { - return brushManager; - } - - public UpdateService getUpdateService() { - return updateService; - } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/ExportedBrushCodec.java b/src/main/java/net/onelitefeather/bettergopaint/brush/ExportedBrushCodec.java new file mode 100644 index 00000000..987ae394 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/ExportedBrushCodec.java @@ -0,0 +1,193 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import net.onelitefeather.bettergopaint.objects.brush.Brush; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import net.onelitefeather.bettergopaint.settings.Settings; +import org.bukkit.Axis; +import org.bukkit.Material; +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Turns brush settings into the versioned text stored on exported items and back. Decoding never + * throws: a missing or unreadable value falls back to the configured default, an unknown brush + * yields an empty result. + */ +public final class ExportedBrushCodec { + + public static final int FORMAT_VERSION = 1; + + private static final String ENTRY_SEPARATOR = ";"; + private static final String VALUE_SEPARATOR = "="; + private static final String LIST_SEPARATOR = ","; + + private ExportedBrushCodec() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + public static @NotNull String encode(@NotNull BrushSettings settings) { + Map values = new LinkedHashMap<>(); + values.put("version", Integer.toString(FORMAT_VERSION)); + values.put("brush", settings.brush().getName()); + values.put("size", Integer.toString(settings.size())); + values.put("chance", Integer.toString(settings.chance())); + values.put("thickness", Integer.toString(settings.thickness())); + values.put("axis", settings.axis().name()); + values.put("angleDistance", Integer.toString(settings.angleDistance())); + values.put("angleHeightDifference", Double.toString(settings.angleHeightDifference())); + values.put("fractureDistance", Integer.toString(settings.fractureDistance())); + values.put("falloff", Integer.toString(settings.falloffStrength())); + values.put("mixing", Integer.toString(settings.mixingStrength())); + values.put("blocks", settings.blocks().stream() + .map(material -> material.getKey().asString()) + .collect(Collectors.joining(LIST_SEPARATOR))); + if (settings.maskEnabled()) { + values.put("mask", settings.mask().getKey().asString()); + } + values.put("surface", settings.surfaceMode().name()); + return values.entrySet().stream() + .map(entry -> entry.getKey() + VALUE_SEPARATOR + entry.getValue()) + .collect(Collectors.joining(ENTRY_SEPARATOR)); + } + + public static @NotNull Optional decode( + @NotNull String encoded, + @NotNull Function> brushByName, + @NotNull Settings settings + ) { + Map values = new LinkedHashMap<>(); + for (String entry : encoded.split(ENTRY_SEPARATOR)) { + int separator = entry.indexOf(VALUE_SEPARATOR); + if (separator > 0) { + values.put(entry.substring(0, separator), entry.substring(separator + 1)); + } + } + String brushName = values.get("brush"); + if (brushName == null) { + return Optional.empty(); + } + return brushByName.apply(brushName).map(brush -> { + ExportedPlayerBrush.Builder builder = ExportedPlayerBrush.builder(brush, settings); + integer(values.get("size")).ifPresent(builder::size); + integer(values.get("chance")).ifPresent(builder::chance); + integer(values.get("thickness")).ifPresent(builder::thickness); + enumValue(Axis.class, values.get("axis")).ifPresent(builder::axis); + integer(values.get("angleDistance")).ifPresent(builder::angleDistance); + decimal(values.get("angleHeightDifference")).ifPresent(builder::angleHeightDifference); + integer(values.get("fractureDistance")).ifPresent(builder::fractureDistance); + integer(values.get("falloff")).ifPresent(builder::falloffStrength); + integer(values.get("mixing")).ifPresent(builder::mixingStrength); + builder.blocks(materials(values.get("blocks"))); + builder.mask(values.containsKey("mask") ? material(values.get("mask")).orElse(null) : null); + enumValue(SurfaceMode.class, values.get("surface")).ifPresent(builder::surfaceMode); + return builder.build(); + }); + } + + /** + * Reads the lore written by BetterGoPaint 1.x, for example {@code Size: 10}. Every line is + * parsed on its own; a line that cannot be read leaves the default in place. + */ + public static @NotNull ExportedPlayerBrush decodeLegacy( + @NotNull Brush brush, + @NotNull List loreLines, + @NotNull Settings settings + ) { + ExportedPlayerBrush.Builder builder = ExportedPlayerBrush.builder(brush, settings); + for (String line : loreLines) { + String value = line.contains(": ") ? line.substring(line.indexOf(": ") + 2).trim() : ""; + if (line.startsWith("Size: ")) { + integer(value).ifPresent(builder::size); + } else if (line.startsWith("Chance: ")) { + integer(value.replace("%", "")).ifPresent(builder::chance); + } else if (line.startsWith("Thickness: ")) { + integer(value).ifPresent(builder::thickness); + } else if (line.startsWith("Axis: ")) { + enumValue(Axis.class, value).ifPresent(builder::axis); + } else if (line.startsWith("FractureDistance: ")) { + integer(value).ifPresent(builder::fractureDistance); + } else if (line.startsWith("AngleDistance: ")) { + integer(value).ifPresent(builder::angleDistance); + } else if (line.startsWith("AngleHeightDifference: ")) { + decimal(value).ifPresent(builder::angleHeightDifference); + } else if (line.startsWith("Mixing: ")) { + integer(value).ifPresent(builder::mixingStrength); + } else if (line.startsWith("Falloff: ")) { + integer(value).ifPresent(builder::falloffStrength); + } else if (line.startsWith("Blocks: ")) { + builder.blocks(materials(value.replace(", ", LIST_SEPARATOR))); + } else if (line.startsWith("Mask: ")) { + material(value).ifPresent(builder::mask); + } else if (line.startsWith("Surface Mode: ")) { + SurfaceMode.byName(value).ifPresent(builder::surfaceMode); + } + } + return builder.build(); + } + + private static Optional integer(String value) { + try { + return value == null ? Optional.empty() : Optional.of(Integer.parseInt(value.trim())); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + private static Optional decimal(String value) { + try { + return value == null ? Optional.empty() : Optional.of(Double.parseDouble(value.trim())); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + private static > Optional enumValue(Class type, String value) { + try { + return value == null ? Optional.empty() : Optional.of(Enum.valueOf(type, value.trim().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + private static Optional material(String value) { + return value == null || value.isBlank() ? Optional.empty() : Optional.ofNullable(Material.matchMaterial(value.trim())); + } + + private static List materials(String value) { + if (value == null || value.isBlank() || value.equals("none")) { + return List.of(); + } + return Arrays.stream(value.split(LIST_SEPARATOR)) + .map(ExportedBrushCodec::material) + .flatMap(Optional::stream) + .filter(Objects::nonNull) + .toList(); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/ExportedBrushes.java b/src/main/java/net/onelitefeather/bettergopaint/brush/ExportedBrushes.java new file mode 100644 index 00000000..55f0aed3 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/ExportedBrushes.java @@ -0,0 +1,83 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.onelitefeather.bettergopaint.settings.Settings; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Reads and writes brushes stored on items. The settings live in the item's persistent data under + * {@code bettergopaint:brush}; name and lore are only for display. + */ +public final class ExportedBrushes { + + public static final NamespacedKey KEY = Objects.requireNonNull(NamespacedKey.fromString("bettergopaint:brush")); + + private ExportedBrushes() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + public static void write(@NotNull ItemMeta meta, @NotNull BrushSettings settings) { + meta.getPersistentDataContainer().set(KEY, PersistentDataType.STRING, ExportedBrushCodec.encode(settings)); + } + + /** + * Reads the brush stored on an item. Items exported by BetterGoPaint 1.x carry their settings + * only in the lore; they are read through the legacy format and converted in place, so the + * caller should put the item back into the inventory when {@code converted} is true. + */ + public static @NotNull Optional read( + @NotNull ItemStack item, + @NotNull PlayerBrushManager brushManager, + @NotNull Settings settings + ) { + if (!item.hasItemMeta()) { + return Optional.empty(); + } + ItemMeta meta = item.getItemMeta(); + String stored = meta.getPersistentDataContainer().get(KEY, PersistentDataType.STRING); + if (stored != null) { + return ExportedBrushCodec.decode(stored, brushManager::getBrushByName, settings).map(brush -> new Result(brush, false)); + } + List lore = meta.lore(); + Component displayName = meta.displayName(); + if (lore == null || lore.isEmpty() || displayName == null) { + return Optional.empty(); + } + PlainTextComponentSerializer plain = PlainTextComponentSerializer.plainText(); + return brushManager.getBrushHandler(plain.serialize(displayName)).map(brush -> { + ExportedPlayerBrush legacy = ExportedBrushCodec.decodeLegacy(brush, lore.stream().map(plain::serialize).toList(), settings); + item.editMeta(converted -> write(converted, legacy)); + return new Result(legacy, true); + }); + } + + public record Result(@NotNull ExportedPlayerBrush brush, boolean converted) { + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/ExportedPlayerBrush.java b/src/main/java/net/onelitefeather/bettergopaint/brush/ExportedPlayerBrush.java index 82eb579f..dff745e9 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/brush/ExportedPlayerBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/ExportedPlayerBrush.java @@ -19,20 +19,16 @@ package net.onelitefeather.bettergopaint.brush; import net.onelitefeather.bettergopaint.objects.brush.Brush; -import net.onelitefeather.bettergopaint.objects.other.Settings; +import net.onelitefeather.bettergopaint.settings.Settings; import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; import org.bukkit.Axis; import org.bukkit.Material; -import org.bukkit.inventory.meta.ItemMeta; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.security.SecureRandom; -import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Objects; -import java.util.Optional; import java.util.Random; public record ExportedPlayerBrush( @@ -91,8 +87,8 @@ public boolean maskEnabled() { return RANDOM; } - public static Builder builder(@NotNull Brush brush) { - return new Builder(brush); + public static Builder builder(@NotNull Brush brush, @NotNull Settings settings) { + return new Builder(brush, settings); } public static final class Builder { @@ -100,7 +96,7 @@ public static final class Builder { private final Brush brush; private List blocks = Collections.emptyList(); - private Axis axis = Settings.settings().generic.DEFAULT_AXIS; + private Axis axis; private SurfaceMode surfaceMode = SurfaceMode.DISABLED; private Material mask; @@ -114,8 +110,17 @@ public static final class Builder { private int mixingStrength; private double angleHeightDifference; - private Builder(@NotNull Brush brush) { + private Builder(@NotNull Brush brush, @NotNull Settings settings) { this.brush = brush; + this.axis = settings.generic().defaultAxis(); + this.size = settings.generic().defaultSize(); + this.chance = settings.generic().defaultChance(); + this.thickness = settings.thickness().defaultThickness(); + this.angleDistance = settings.angle().defaultAngleDistance(); + this.fractureDistance = settings.fracture().defaultFractureDistance(); + this.falloffStrength = settings.generic().defaultFalloffStrength(); + this.mixingStrength = settings.generic().defaultMixingStrength(); + this.angleHeightDifference = settings.angle().defaultAngleHeightDifference(); } public @NotNull Builder surfaceMode(SurfaceMode surfaceMode) { @@ -184,42 +189,4 @@ private Builder(@NotNull Brush brush) { } - @Deprecated(forRemoval = true) - public static ExportedPlayerBrush parse(@NotNull Brush brush, @NotNull ItemMeta itemMeta) { - ExportedPlayerBrush.Builder builder = ExportedPlayerBrush.builder(brush); - Optional.ofNullable(itemMeta.getLore()).ifPresent(lore -> lore.stream() - .map(line -> line.replace("§8", "")) - .forEach(line -> { - if (line.startsWith("Size: ")) { - builder.size(Integer.parseInt(line.substring(6))); - } else if (line.startsWith("Chance: ")) { - builder.chance(Integer.parseInt(line.substring(8, line.length() - 1))); - } else if (line.startsWith("Thickness: ")) { - builder.thickness(Integer.parseInt(line.substring(11))); - } else if (line.startsWith("Axis: ")) { - builder.axis(Axis.valueOf(line.substring(6).toUpperCase())); - } else if (line.startsWith("FractureDistance: ")) { - builder.fractureDistance(Integer.parseInt(line.substring(18))); - } else if (line.startsWith("AngleDistance: ")) { - builder.angleDistance(Integer.parseInt(line.substring(15))); - } else if (line.startsWith("AngleHeightDifference: ")) { - builder.angleHeightDifference(Double.parseDouble(line.substring(23))); - } else if (line.startsWith("Mixing: ")) { - builder.mixingStrength(Integer.parseInt(line.substring(8))); - } else if (line.startsWith("Falloff: ")) { - builder.falloffStrength(Integer.parseInt(line.substring(9))); - } else if (line.startsWith("Blocks: ")) { - builder.blocks(Arrays.stream(line.substring(8).split(", ")) - .map(Material::matchMaterial) - .filter(Objects::nonNull) - .toList()); - } else if (line.startsWith("Mask: ")) { - builder.mask(Material.matchMaterial(line.substring(6))); - } else if (line.startsWith("Surface Mode: ")) { - SurfaceMode.byName(line.substring(14)).ifPresent(builder::surfaceMode); - } - })); - return builder.build(); - } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrush.java b/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrush.java index d7108ab5..aee0ff76 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrush.java @@ -19,9 +19,8 @@ package net.onelitefeather.bettergopaint.brush; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.format.Style; import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.translation.GlobalTranslator; import net.onelitefeather.bettergopaint.objects.brush.AngleBrush; import net.onelitefeather.bettergopaint.objects.brush.Brush; import net.onelitefeather.bettergopaint.objects.brush.DiscBrush; @@ -31,7 +30,8 @@ import net.onelitefeather.bettergopaint.objects.brush.SplatterBrush; import net.onelitefeather.bettergopaint.objects.brush.SprayBrush; import net.onelitefeather.bettergopaint.objects.brush.UnderlayBrush; -import net.onelitefeather.bettergopaint.objects.other.Settings; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; import net.onelitefeather.bettergopaint.utils.GUI; import org.bukkit.Axis; @@ -46,6 +46,7 @@ import java.security.SecureRandom; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Random; import java.util.stream.Collectors; @@ -55,6 +56,7 @@ public final class PlayerBrush implements BrushSettings { private final PlayerBrushManager brushManager; + private final SettingsHolder settings; private final Random random = new SecureRandom(); private boolean maskEnabled; @@ -74,24 +76,27 @@ public final class PlayerBrush implements BrushSettings { private Material mask; private final List blocks = new ArrayList<>(); + private final Locale locale; private final Inventory gui; - public PlayerBrush(@NotNull PlayerBrushManager brushManager) { + public PlayerBrush(@NotNull PlayerBrushManager brushManager, @NotNull SettingsHolder settings, @NotNull Locale locale) { this.brushManager = brushManager; - - surfaceMode = Settings.settings().generic.SURFACE_MODE; - maskEnabled = Settings.settings().generic.MASK_ENABLED; - enabled = Settings.settings().generic.ENABLED_BY_DEFAULT; - chance = Settings.settings().generic.DEFAULT_CHANCE; - thickness = Settings.settings().thickness.DEFAULT_THICKNESS; - fractureDistance = Settings.settings().fracture.DEFAULT_FRACTURE_DISTANCE; - angleDistance = Settings.settings().angle.DEFAULT_ANGLE_DISTANCE; - angleHeightDifference = Settings.settings().angle.DEFAULT_ANGLE_HEIGHT_DIFFERENCE; - falloffStrength = Settings.settings().generic.DEFAULT_FALLOFF_STRENGTH; - mixingStrength = Settings.settings().generic.DEFAULT_MIXING_STRENGTH; - axis = Settings.settings().generic.DEFAULT_AXIS; - size = Settings.settings().generic.DEFAULT_SIZE; - mask = Settings.settings().generic.DEFAULT_MASK; + this.settings = settings; + this.locale = locale; + + surfaceMode = settings.get().generic().surfaceMode(); + maskEnabled = settings.get().generic().maskEnabled(); + enabled = settings.get().generic().enabledByDefault(); + chance = settings.get().generic().defaultChance(); + thickness = settings.get().thickness().defaultThickness(); + fractureDistance = settings.get().fracture().defaultFractureDistance(); + angleDistance = settings.get().angle().defaultAngleDistance(); + angleHeightDifference = settings.get().angle().defaultAngleHeightDifference(); + falloffStrength = settings.get().generic().defaultFalloffStrength(); + mixingStrength = settings.get().generic().defaultMixingStrength(); + axis = settings.get().generic().defaultAxis(); + size = settings.get().generic().defaultSize(); + mask = settings.get().generic().defaultMask(); brush = brushManager.cycleForward(null); blocks.add(Material.STONE); gui = GUI.create(this); @@ -184,6 +189,27 @@ public int thickness() { return axis; } + /** + * @return the locale the menu of this brush is rendered in + */ + public @NotNull Locale locale() { + return locale; + } + + /** + * @return the current settings snapshot + */ + public @NotNull Settings settings() { + return settings.get(); + } + + /** + * @return every brush the player can cycle through + */ + public @NotNull List brushes() { + return brushManager.getBrushes(); + } + public void updateInventory() { GUI.update(gui, this); } @@ -248,10 +274,10 @@ public void cycleBrushBackwards() { } public void setSize(int size) { - if (size <= Settings.settings().generic.MAX_SIZE && size > 0) { + if (size <= settings.get().generic().maxSize() && size > 0) { this.size = size; - } else if (size > Settings.settings().generic.MAX_SIZE) { - this.size = Settings.settings().generic.MAX_SIZE; + } else if (size > settings.get().generic().maxSize()) { + this.size = settings.get().generic().maxSize(); } else { this.size = 1; } @@ -264,13 +290,13 @@ public Inventory getInventory() { public void increaseBrushSize(boolean x10) { if (x10) { - if (size + 10 <= Settings.settings().generic.MAX_SIZE) { + if (size + 10 <= settings.get().generic().maxSize()) { size += 10; } else { - size = Settings.settings().generic.MAX_SIZE; + size = settings.get().generic().maxSize(); } } else { - if (size < Settings.settings().generic.MAX_SIZE) { + if (size < settings.get().generic().maxSize()) { size += 1; } } @@ -312,7 +338,7 @@ public void decreaseChance() { } public void increaseThickness() { - if (thickness < Settings.settings().thickness.MAX_THICKNESS) { + if (thickness < settings.get().thickness().maxThickness()) { thickness += 1; } updateInventory(); @@ -326,7 +352,7 @@ public void decreaseThickness() { } public void increaseAngleDistance() { - if (angleDistance < Settings.settings().angle.MAX_ANGLE_DISTANCE) { + if (angleDistance < settings.get().angle().maxAngleDistance()) { angleDistance += 1; } updateInventory(); @@ -340,7 +366,7 @@ public void decreaseAngleDistance() { } public void increaseFractureDistance() { - if (this.fractureDistance < Settings.settings().fracture.MAX_FRACTURE_DISTANCE) { + if (this.fractureDistance < settings.get().fracture().maxFractureDistance()) { this.fractureDistance += 1; } updateInventory(); @@ -359,8 +385,8 @@ public void increaseAngleHeightDifference(boolean d15) { } else { angleHeightDifference += 5.0; } - if (angleHeightDifference > Settings.settings().angle.MAX_ANGLE_HEIGHT_DIFFERENCE) { - angleHeightDifference = Settings.settings().angle.MAX_ANGLE_HEIGHT_DIFFERENCE; + if (angleHeightDifference > settings.get().angle().maxAngleHeightDifference()) { + angleHeightDifference = settings.get().angle().maxAngleHeightDifference(); } updateInventory(); } @@ -371,8 +397,8 @@ public void decreaseAngleHeightDifference(boolean d15) { } else { angleHeightDifference -= 5.0; } - if (angleHeightDifference < Settings.settings().angle.MIN_ANGLE_HEIGHT_DIFFERENCE) { - angleHeightDifference = Settings.settings().angle.MIN_ANGLE_HEIGHT_DIFFERENCE; + if (angleHeightDifference < settings.get().angle().minAngleHeightDifference()) { + angleHeightDifference = settings.get().angle().minAngleHeightDifference(); } updateInventory(); } @@ -400,48 +426,56 @@ public void cycleAxis() { updateInventory(); } - public void export(ItemStack itemStack) { - List lore = new ArrayList<>(); - lore.add(""); - lore.add("Size: " + size); + /** + * Stores this brush on the given item. The settings go into the item's persistent data, the + * lore only shows them. + */ + public void export(@NotNull ItemStack itemStack) { + List lore = new ArrayList<>(); + lore.add(Component.empty()); + lore.add(line("bettergopaint.export.size", size)); if (brush() instanceof SprayBrush) { - lore.add("Chance: " + chance() + "%"); + lore.add(line("bettergopaint.export.chance", chance())); } else if (brush() instanceof OverlayBrush || brush() instanceof UnderlayBrush) { - lore.add("Thickness: " + thickness()); + lore.add(line("bettergopaint.export.thickness", thickness())); } else if (brush() instanceof DiscBrush) { - lore.add("Axis: " + axis().name()); + lore.add(line("bettergopaint.export.axis", axis().name())); } else if (brush() instanceof AngleBrush) { - lore.add("AngleDistance: " + angleDistance()); - lore.add("AngleHeightDifference: " + angleHeightDifference()); + lore.add(line("bettergopaint.export.angle_distance", angleDistance())); + lore.add(line("bettergopaint.export.angle_height", angleHeightDifference())); } else if (brush() instanceof SplatterBrush) { - lore.add("Falloff: " + falloffStrength()); + lore.add(line("bettergopaint.export.falloff", falloffStrength())); } else if (brush() instanceof GradientBrush) { - lore.add("Mixing: " + mixingStrength()); - lore.add("Falloff: " + falloffStrength()); + lore.add(line("bettergopaint.export.mixing", mixingStrength())); + lore.add(line("bettergopaint.export.falloff", falloffStrength())); } else if (brush() instanceof FractureBrush) { - lore.add("FractureDistance: " + fractureDistance()); + lore.add(line("bettergopaint.export.fracture_distance", fractureDistance())); } - lore.add("Blocks: " + (blocks().isEmpty() ? "none" : blocks().stream() + lore.add(line("bettergopaint.export.blocks", blocks().isEmpty() ? "-" : blocks().stream() .map(Material::getKey) .map(NamespacedKey::asMinimalString) .collect(Collectors.joining(", ")))); - if (maskEnabled()) { - lore.add("Mask: " + mask().getKey().asMinimalString()); + lore.add(line("bettergopaint.export.mask", mask().getKey().asMinimalString())); } if (!surfaceMode().equals(SurfaceMode.DISABLED)) { - lore.add("Surface Mode: " + surfaceMode.getName()); + lore.add(line("bettergopaint.export.surface", surfaceMode.getName())); } itemStack.editMeta(itemMeta -> { - itemMeta.displayName(Component.text(" ♦ " + brush().getName() + " ♦ ", NamedTextColor.AQUA) - .style(Style.style(TextDecoration.ITALIC.withState(false)))); - itemMeta.lore(lore.stream().map(string -> Component.text(string).style(Style - .style(TextDecoration.ITALIC.withState(false)) - .color(NamedTextColor.DARK_GRAY))).toList()); + itemMeta.displayName(GlobalTranslator.render( + Component.translatable("bettergopaint.export.name", Component.text(brush().getName())), locale + ).decorationIfAbsent(TextDecoration.ITALIC, TextDecoration.State.FALSE)); + itemMeta.lore(lore); itemMeta.addEnchant(Enchantment.INFINITY, 1, false); itemMeta.addItemFlags(ItemFlag.HIDE_ENCHANTS); + ExportedBrushes.write(itemMeta, this); }); } + private Component line(String key, Object value) { + return GlobalTranslator.render(Component.translatable(key, Component.text(String.valueOf(value))), locale) + .decorationIfAbsent(TextDecoration.ITALIC, TextDecoration.State.FALSE); + } + } diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManager.java b/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManager.java index 751e35be..2fd180b6 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManager.java +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManager.java @@ -18,6 +18,9 @@ */ package net.onelitefeather.bettergopaint.brush; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; import net.onelitefeather.bettergopaint.objects.brush.AngleBrush; import net.onelitefeather.bettergopaint.objects.brush.Brush; import net.onelitefeather.bettergopaint.objects.brush.BucketBrush; @@ -34,22 +37,44 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Optional; import java.util.UUID; -import java.util.stream.Collectors; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; /** * The PlayerBrushManager class manages the brush selection for each player. */ +@Singleton public class PlayerBrushManager { - private final HashMap playerBrushes; + /** + * Written from the region thread of each player and read from commands and the FAWE worker, so it must be + * safe for concurrent use. + */ + private final Map playerBrushes; + private final BiFunction brushFactory; private final List brushes; + private final PaintBrush paintBrush; + + private final SettingsHolder settings; + + @Inject + public PlayerBrushManager(@NotNull SettingsHolder settings) { + this(settings, (manager, locale) -> new PlayerBrush(manager, settings, locale)); + } - public PlayerBrushManager() { - this.playerBrushes = new HashMap<>(); + PlayerBrushManager( + @NotNull SettingsHolder settings, + @NotNull BiFunction brushFactory + ) { + this.settings = settings; + this.brushFactory = brushFactory; + this.playerBrushes = new ConcurrentHashMap<>(); + this.paintBrush = new PaintBrush(settings); this.brushes = List.of( new SphereBrush(), new SprayBrush(), @@ -61,7 +86,7 @@ public PlayerBrushManager() { new UnderlayBrush(), new FractureBrush(), new GradientBrush(), - new PaintBrush() + paintBrush ); } @@ -72,24 +97,7 @@ public PlayerBrushManager() { * @return The brush for the specified player. */ public @NotNull PlayerBrush getBrush(@NotNull Player player) { - return playerBrushes.computeIfAbsent(player.getUniqueId(), ignored -> new PlayerBrush(this)); - } - - /** - * Retrieves the lore for a specific brush. Each brush name is preceded by a color code - * indicating whether it is the currently selected brush or not. - * - * @param brush The brush for which to retrieve the lore. - * @return The lore for the specified brush. - */ - public @NotNull String getBrushLore(@NotNull Brush brush) { - return brushes.stream().map(current -> { - if (current.equals(brush)) { - return "§e" + current.getName() + "\n"; - } else { - return "§8" + current.getName() + "\n"; - } - }).collect(Collectors.joining()); + return playerBrushes.computeIfAbsent(player.getUniqueId(), ignored -> brushFactory.apply(this, player.locale())); } /** @@ -104,6 +112,18 @@ public PlayerBrushManager() { .findAny(); } + /** + * Retrieves the brush with exactly the given name. + * + * @param name The name of the brush. + * @return An optional containing the brush, or empty if no brush has that name. + */ + public @NotNull Optional getBrushByName(@NotNull String name) { + return brushes.stream() + .filter(brush -> brush.getName().equals(name)) + .findFirst(); + } + /** * Retrieves the list of available brushes. * @@ -114,12 +134,13 @@ public PlayerBrushManager() { } /** - * Removes the player from the {@link #playerBrushes} map. + * Removes the player's brush settings and any unfinished Paint Brush stroke. * * @param player The player who should be removed. */ public void removeBrush(@NotNull Player player) { playerBrushes.remove(player.getUniqueId()); + paintBrush.forget(player.getUniqueId()); } /** diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/SingleTypeMask.java b/src/main/java/net/onelitefeather/bettergopaint/brush/SingleTypeMask.java new file mode 100644 index 00000000..df28e1d7 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/SingleTypeMask.java @@ -0,0 +1,85 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.extent.NullExtent; +import com.sk89q.worldedit.function.mask.AbstractExtentMask; +import com.sk89q.worldedit.function.mask.Mask; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; +import org.jetbrains.annotations.NotNull; + +/** + * A WorldEdit mask that accepts blocks of exactly one type. It is the BetterGoPaint mask of a brush. + *

+ * WorldEdit's own {@code BlockTypeMask} could do the same, but it sizes a lookup table from the block + * registry when it is created, which only exists on a running server. This mask compares the type + * directly, so it works against any extent and in unit tests. + */ +public final class SingleTypeMask extends AbstractExtentMask { + + private final BlockType type; + + /** + * Creates a mask for the given block type. It is not bound to an extent, because the extent of a + * stroke is only known once the stroke runs. Test it with {@link #test(Extent, BlockVector3)}. + * + * @param type the accepted block type + */ + public SingleTypeMask(@NotNull BlockType type) { + this(new NullExtent(), type); + } + + /** + * Creates a mask for the given block type that reads from the given extent when it is tested + * through {@link #test(BlockVector3)}. + * + * @param extent the extent to read from + * @param type the accepted block type + */ + public SingleTypeMask(@NotNull Extent extent, @NotNull BlockType type) { + super(extent); + this.type = type; + } + + /** + * Returns the accepted block type. + * + * @return the block type + */ + public @NotNull BlockType type() { + return type; + } + + @Override + public boolean test(@NotNull BlockVector3 position) { + return test(getExtent(), position); + } + + @Override + public boolean test(@NotNull Extent extent, @NotNull BlockVector3 position) { + return extent.getBlock(position).getBlockType().equals(type); + } + + @Override + public @NotNull Mask copy() { + return new SingleTypeMask(getExtent(), type); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContext.java b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContext.java new file mode 100644 index 00000000..d1af3e84 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContext.java @@ -0,0 +1,128 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.function.mask.AbstractExtentMask; +import com.sk89q.worldedit.function.mask.Mask; +import com.sk89q.worldedit.function.pattern.Pattern; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; +import com.sk89q.worldedit.world.block.BlockType; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import org.bukkit.Axis; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Random; + +/** + * Everything a brush needs to compute a stroke, captured on the player's thread before the stroke is + * queued. The context only holds WorldEdit types and plain values, so a brush never has to reach + * back into Bukkit while the stroke runs. + * + * @param target the position the stroke is centred on + * @param points the points of a multi-click stroke in click order, the last one is the {@code target} + * @param eye the position of the player's eyes, used by the relative surface mode + * @param palette the pattern that picks the block to place + * @param blocks the palette blocks in the order they were configured, used by the gradient brush + * @param mask the BetterGoPaint mask, or null if the mask is disabled + * @param axis the axis of the disc brush + * @param surfaceMode the surface mode of the brush + * @param size the diameter of the brush + * @param chance the chance in percent of the spray brush + * @param falloffStrength the falloff strength in percent + * @param mixingStrength the mixing strength in percent of the gradient brush + * @param thickness the thickness of the overlay and underlay brush + * @param fractureDistance the distance of the fracture brush + * @param angleDistance the distance of the angle brush + * @param angleHeightDifference the height difference in degrees of the angle brush + * @param random the source of randomness of the stroke + */ +public record StrokeContext( + @NotNull BlockVector3 target, + @NotNull List points, + @NotNull Vector3 eye, + @NotNull Pattern palette, + @NotNull List blocks, + @Nullable Mask mask, + @NotNull Axis axis, + @NotNull SurfaceMode surfaceMode, + int size, + int chance, + int falloffStrength, + int mixingStrength, + int thickness, + int fractureDistance, + int angleDistance, + double angleHeightDifference, + @NotNull Random random +) { + + public StrokeContext { + points = List.copyOf(points); + blocks = List.copyOf(blocks); + } + + /** + * Creates a copy of this context for another target, palette and size. This is how the arguments of + * {@link com.sk89q.worldedit.command.tool.brush.Brush#build} reach the brush. + * + * @param target the new target + * @param palette the new palette + * @param size the new size + * @return the new context + */ + public @NotNull StrokeContext with(@NotNull BlockVector3 target, @NotNull Pattern palette, int size) { + List newPoints = this.target.equals(target) ? points : List.of(target); + return new StrokeContext(target, newPoints, eye, palette, blocks, mask, axis, surfaceMode, size, chance, + falloffStrength, mixingStrength, thickness, fractureDistance, angleDistance, angleHeightDifference, + random); + } + + /** + * Creates a copy of this context with the points of a multi-click stroke. + * + * @param points the points in click order + * @return the new context + */ + public @NotNull StrokeContext withPoints(@NotNull List points) { + return new StrokeContext(target, points, eye, palette, blocks, mask, axis, surfaceMode, size, chance, + falloffStrength, mixingStrength, thickness, fractureDistance, angleDistance, angleHeightDifference, + random); + } + + /** + * Checks if the BetterGoPaint mask lets the block at the position pass. + * + * @param extent the extent to read the block from + * @param position the position to check + * @return true if the mask is disabled or accepts the block + */ + public boolean passesMask(@NotNull Extent extent, @NotNull BlockVector3 position) { + if (mask == null) { + return true; + } + if (mask instanceof AbstractExtentMask extentMask) { + return extentMask.test(extent, position); + } + return mask.test(position); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactory.java b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactory.java new file mode 100644 index 00000000..31159806 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactory.java @@ -0,0 +1,115 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import com.fastasyncworldedit.core.math.random.SimpleRandom; +import com.sk89q.worldedit.function.mask.Mask; +import com.sk89q.worldedit.function.pattern.RandomPattern; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; +import com.sk89q.worldedit.world.block.BlockType; +import org.bukkit.Location; +import org.bukkit.Material; +import org.jetbrains.annotations.NotNull; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.function.Function; + +/** + * Captures a {@link StrokeContext} on the player's thread. This is the one place where Bukkit values + * are turned into WorldEdit values. It only reads coordinates from the locations and never asks + * their world for anything. + */ +public final class StrokeContextFactory { + + /** + * Height of the player's eyes above the feet that the relative surface mode has always used. + */ + private static final double EYE_HEIGHT = 1.5; + + private StrokeContextFactory() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + /** + * Captures the context of a stroke. + * + * @param target the clicked block + * @param playerLocation the location of the player's feet + * @param settings the brush settings to snapshot + * @param blockTypes converts a material to its WorldEdit block type + * @param random the source of randomness of the stroke + * @return the context of the stroke + */ + public static @NotNull StrokeContext capture( + @NotNull Location target, + @NotNull Location playerLocation, + @NotNull BrushSettings settings, + @NotNull Function blockTypes, + @NotNull Random random + ) { + BlockVector3 position = BlockVector3.at(target.getBlockX(), target.getBlockY(), target.getBlockZ()); + Vector3 eye = Vector3.at(playerLocation.getX(), playerLocation.getY() + EYE_HEIGHT, playerLocation.getZ()); + + List blocks = settings.blocks().stream().map(blockTypes).toList(); + Mask mask = settings.maskEnabled() ? new SingleTypeMask(blockTypes.apply(settings.mask())) : null; + + return new StrokeContext( + position, + List.of(position), + eye, + palette(blocks, random), + blocks, + mask, + settings.axis(), + settings.surfaceMode(), + settings.size(), + settings.chance(), + settings.falloffStrength(), + settings.mixingStrength(), + settings.thickness(), + settings.fractureDistance(), + settings.angleDistance(), + settings.angleHeightDifference(), + random + ); + } + + /** + * Builds the palette pattern. Every entry of the block list has the same weight, so a block that is + * listed twice is twice as likely. + * + * @param blocks the palette blocks + * @param random the source of randomness + * @return the palette pattern + */ + static @NotNull RandomPattern palette(@NotNull List blocks, @NotNull Random random) { + SimpleRandom simpleRandom = (x, y, z) -> random.nextDouble(); + Map weights = new LinkedHashMap<>(); + for (BlockType block : blocks) { + weights.merge(block, 1, Integer::sum); + } + RandomPattern pattern = new RandomPattern(simpleRandom); + weights.forEach(pattern::add); + return pattern; + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeRunner.java b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeRunner.java new file mode 100644 index 00000000..7eee4fa9 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/brush/StrokeRunner.java @@ -0,0 +1,60 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import com.sk89q.worldedit.EditSession; +import com.sk89q.worldedit.LocalSession; +import com.sk89q.worldedit.command.tool.brush.Brush; +import com.sk89q.worldedit.extension.platform.Actor; +import org.jetbrains.annotations.NotNull; + +/** + * Runs a stroke inside an edit session of the player's WorldEdit session. The edit session carries the + * global mask and the limits of the player, and the stroke is remembered as one history entry. + */ +public final class StrokeRunner { + + private StrokeRunner() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + /** + * Runs the stroke. The actor and the session must be looked up on the player's thread, this method + * touches nothing from Bukkit and is meant to run in WorldEdit's action queue. + * + * @param actor the actor that paints + * @param localSession the WorldEdit session of the actor + * @param stroke the stroke to run + * @param context the context of the stroke + */ + public static void run( + @NotNull Actor actor, + @NotNull LocalSession localSession, + @NotNull Brush stroke, + @NotNull StrokeContext context + ) { + try (EditSession editSession = localSession.createEditSession(actor)) { + try { + stroke.build(editSession, context.target(), context.palette(), context.size()); + } finally { + localSession.remember(editSession); + } + } + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/command/BrushSizes.java b/src/main/java/net/onelitefeather/bettergopaint/command/BrushSizes.java new file mode 100644 index 00000000..e917883b --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/command/BrushSizes.java @@ -0,0 +1,51 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.command; + +import java.util.List; +import java.util.stream.IntStream; + +/** + * Validation and tab completion for brush sizes. The upper bound is passed in on every call so a + * config reload takes effect without registering the command again. + */ +public final class BrushSizes { + + public static final int MIN_SIZE = 1; + + private BrushSizes() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + public static boolean isValid(int size, int maxSize) { + return size >= MIN_SIZE && size <= maxSize; + } + + /** + * Suggests sizes that start with what the player typed so far: the first ten sizes, every + * multiple of five and the maximum. + */ + public static List suggestions(String input, int maxSize) { + return IntStream.rangeClosed(MIN_SIZE, maxSize) + .filter(size -> size <= 10 || size % 5 == 0 || size == maxSize) + .mapToObj(Integer::toString) + .filter(size -> size.startsWith(input)) + .toList(); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/command/GoPaintCommand.java b/src/main/java/net/onelitefeather/bettergopaint/command/GoPaintCommand.java index 6c36c0bc..b34da135 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/command/GoPaintCommand.java +++ b/src/main/java/net/onelitefeather/bettergopaint/command/GoPaintCommand.java @@ -18,104 +18,123 @@ */ package net.onelitefeather.bettergopaint.command; -import net.onelitefeather.bettergopaint.BetterGoPaint; -import net.onelitefeather.bettergopaint.objects.other.Settings; +import net.kyori.adventure.text.Component; +import jakarta.inject.Inject; import net.onelitefeather.bettergopaint.brush.PlayerBrush; +import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; import net.onelitefeather.bettergopaint.utils.Constants; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.bukkit.command.PluginIdentifiableCommand; +import net.onelitefeather.bettergopaint.utils.Messages; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; +import org.incendo.cloud.annotations.Argument; +import org.incendo.cloud.annotations.Command; +import org.incendo.cloud.annotations.CommandDescription; +import org.incendo.cloud.annotations.Permission; +import org.incendo.cloud.annotations.suggestion.Suggestions; +import org.incendo.cloud.context.CommandContext; +import org.incendo.cloud.context.CommandInput; +import org.incendo.cloud.minecraft.extras.MinecraftHelp; +import org.incendo.cloud.paper.util.sender.PlayerSource; +import org.incendo.cloud.paper.util.sender.Source; import org.jetbrains.annotations.NotNull; import java.util.List; -public class GoPaintCommand extends Command implements PluginIdentifiableCommand { +/** + * The {@code /gopaint} command tree. Every subcommand needs {@link Constants#USE_PERMISSION} and its own node. + */ +public final class GoPaintCommand { - private final BetterGoPaint plugin; + private final Plugin plugin; + private final PlayerBrushManager brushManager; + private final SettingsHolder settings; + private final MinecraftHelp help; - public GoPaintCommand(BetterGoPaint main) { - super("gopaint", "goPaint command", "/gp size|toggle|info|reload", List.of("gp")); - plugin = main; + @Inject + public GoPaintCommand( + @NotNull Plugin plugin, + @NotNull PlayerBrushManager brushManager, + @NotNull SettingsHolder settings, + @NotNull MinecraftHelp help + ) { + this.plugin = plugin; + this.brushManager = brushManager; + this.settings = settings; + this.help = help; } - @Override - public boolean execute( - @NotNull final CommandSender sender, - @NotNull final String commandLabel, - final @NotNull String[] args - ) { - if (!(sender instanceof final Player p)) { - return false; - } - PlayerBrush pb = plugin.getBrushManager().getBrush(p); - String prefix = Settings.settings().generic.PREFIX; - if (!p.hasPermission(Constants.USE_PERMISSION)) { - p.sendRichMessage(prefix + "You are lacking the permission bettergopaint.use"); - return true; - } - if (args.length == 0) { - if (p.hasPermission(Constants.ADMIN_PERMISSION)) { - p.sendRichMessage(prefix + "/gp size|toggle|info|reload"); - return true; - } - p.sendRichMessage(prefix + "/gp size|toggle|info"); - return true; - } else if (args.length == 1) { - if (args[0].equalsIgnoreCase("size")) { - p.sendRichMessage(prefix + "/gp size [number]"); - return true; - } else if (args[0].equalsIgnoreCase("toggle")) { - if (pb.enabled()) { - pb.toggle(); - p.sendRichMessage(prefix + "Disabled brush"); - } else { - pb.toggle(); - p.sendRichMessage(prefix + "Enabled brush"); - } - return true; - } else if ((args[0].equalsIgnoreCase("reload") || args[0].equalsIgnoreCase("r")) && p.hasPermission( - Constants.ADMIN_PERMISSION)) { - plugin.reloadConfig(); - p.sendRichMessage(prefix + "Reloaded"); - return true; - } else if (args[0].equalsIgnoreCase("info") || args[0].equalsIgnoreCase("i")) { - p.sendRichMessage(prefix + "Created by: TheMeinerLP"); - p.sendRichMessage(prefix + "Links: Twitter"); - return true; - } - if (p.hasPermission(Constants.ADMIN_PERMISSION)) { - p.sendRichMessage(prefix + "/gp size|toggle|info|reload"); - return true; - } - p.sendRichMessage(prefix + "/gp size|toggle|info"); - return true; - } else if (args.length == 2) { - if (args[0].equalsIgnoreCase("size") || args[0].equalsIgnoreCase("s")) { - try { - int sizeAmount = Integer.parseInt(args[1]); - pb.setSize(sizeAmount); - p.sendRichMessage(prefix + "Size set to: " + pb.size()); - return true; - } catch (Exception e) { - p.sendRichMessage(prefix + "/gb size [number]"); - return true; - } - } - if (p.hasPermission(Constants.ADMIN_PERMISSION)) { - p.sendRichMessage(prefix + "/gp size|toggle|info|reload"); - return true; - } - p.sendRichMessage(prefix + "/gp size|toggle|info"); - return true; + @Command("gopaint|gp|bgp") + @CommandDescription("Shows the BetterGoPaint commands") + @Permission(value = {Constants.USE_PERMISSION, Constants.HELP_PERMISSION}, mode = Permission.Mode.ALL_OF) + public void root(Source sender) { + help.queryCommands("", sender); + } + + @Command("gopaint|gp|bgp help [query]") + @CommandDescription("Shows the BetterGoPaint commands") + @Permission(value = {Constants.USE_PERMISSION, Constants.HELP_PERMISSION}, mode = Permission.Mode.ALL_OF) + public void help(Source sender, @Argument("query") String query) { + help.queryCommands(query == null ? "" : query, sender); + } + + @Command("gopaint|gp|bgp size ") + @CommandDescription("Sets the size of your brush") + @Permission(value = {Constants.USE_PERMISSION, Constants.SIZE_PERMISSION}, mode = Permission.Mode.ALL_OF) + public void size(PlayerSource sender, @Argument(value = "size", suggestions = "brush-sizes") int size) { + Player player = sender.source(); + int maxSize = settings.get().generic().maxSize(); + if (!BrushSizes.isValid(size, maxSize)) { + player.sendMessage(Component.translatable( + "bettergopaint.command.size.out_of_range", + Messages.prefix(settings.get()), + Component.text(BrushSizes.MIN_SIZE), + Component.text(maxSize) + )); + return; } - return false; + PlayerBrush brush = brushManager.getBrush(player); + brush.setSize(size); + player.sendMessage(Component.translatable( + "bettergopaint.command.size.set", + Messages.prefix(settings.get()), + Component.text(brush.size()) + )); + } + + @Suggestions("brush-sizes") + public List brushSizes(CommandContext context, CommandInput input) { + return BrushSizes.suggestions(input.lastRemainingToken(), settings.get().generic().maxSize()); } - @Override - public @NotNull Plugin getPlugin() { - return plugin; + @Command("gopaint|gp|bgp toggle") + @CommandDescription("Enables or disables your brush") + @Permission(value = {Constants.USE_PERMISSION, Constants.TOGGLE_PERMISSION}, mode = Permission.Mode.ALL_OF) + public void toggle(PlayerSource sender) { + Player player = sender.source(); + PlayerBrush brush = brushManager.getBrush(player); + brush.toggle(); + player.sendMessage(brush.enabled() + ? Component.translatable("bettergopaint.command.toggle.enabled", Messages.prefix(settings.get())) + : Component.translatable("bettergopaint.command.toggle.disabled", Messages.prefix(settings.get()))); } + @Command("gopaint|gp|bgp info") + @CommandDescription("Shows who made BetterGoPaint") + @Permission(value = {Constants.USE_PERMISSION, Constants.INFO_PERMISSION}, mode = Permission.Mode.ALL_OF) + public void info(Source sender) { + sender.source().sendMessage(Component.translatable( + "bettergopaint.command.info", + Messages.prefix(settings.get()), + Component.text(plugin.getPluginMeta().getVersion()) + )); + } + + @Command("gopaint|gp|bgp reload") + @CommandDescription("Reloads config.yml") + @Permission(value = {Constants.USE_PERMISSION, Constants.RELOAD_PERMISSION}, mode = Permission.Mode.ALL_OF) + public void reload(Source sender) { + settings.reload(); + sender.source().sendMessage(Component.translatable("bettergopaint.command.reload.done", Messages.prefix(settings.get()))); + } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/command/ReloadCommand.java b/src/main/java/net/onelitefeather/bettergopaint/injection/BrushModule.java similarity index 56% rename from src/main/java/net/onelitefeather/bettergopaint/command/ReloadCommand.java rename to src/main/java/net/onelitefeather/bettergopaint/injection/BrushModule.java index f7769a86..33474b6a 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/command/ReloadCommand.java +++ b/src/main/java/net/onelitefeather/bettergopaint/injection/BrushModule.java @@ -16,25 +16,18 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package net.onelitefeather.bettergopaint.command; +package net.onelitefeather.bettergopaint.injection; -import net.onelitefeather.bettergopaint.BetterGoPaint; -import net.onelitefeather.bettergopaint.utils.Constants; -import org.bukkit.entity.Player; -import org.incendo.cloud.annotations.Command; -import org.incendo.cloud.annotations.Permission; +import com.google.inject.AbstractModule; +import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; -public final class ReloadCommand { - - private final BetterGoPaint betterGoPaint; - - public ReloadCommand(final BetterGoPaint betterGoPaint) { - this.betterGoPaint = betterGoPaint; - } +/** + * Binds the brushes and the per-player brush state. + */ +public final class BrushModule extends AbstractModule { - @Command("bgp|gp reload") - @Permission(Constants.RELOAD_PERMISSION) - public void onReload(Player player) { - betterGoPaint.reloadConfig(); + @Override + protected void configure() { + bind(PlayerBrushManager.class); } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/injection/CommandModule.java b/src/main/java/net/onelitefeather/bettergopaint/injection/CommandModule.java new file mode 100644 index 00000000..d8973b33 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/injection/CommandModule.java @@ -0,0 +1,50 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.injection; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import jakarta.inject.Singleton; +import org.bukkit.plugin.Plugin; +import org.incendo.cloud.CommandManager; +import org.incendo.cloud.execution.ExecutionCoordinator; +import org.incendo.cloud.minecraft.extras.MinecraftHelp; +import org.incendo.cloud.paper.PaperCommandManager; +import org.incendo.cloud.paper.util.sender.PaperSimpleSenderMapper; +import org.incendo.cloud.paper.util.sender.Source; + +/** + * Provides the cloud command manager and the generated help. + */ +public final class CommandModule extends AbstractModule { + + @Provides + @Singleton + CommandManager commandManager(Plugin plugin) { + return PaperCommandManager.builder(PaperSimpleSenderMapper.simpleSenderMapper()) + .executionCoordinator(ExecutionCoordinator.simpleCoordinator()) + .buildOnEnable(plugin); + } + + @Provides + @Singleton + MinecraftHelp help(CommandManager commandManager) { + return MinecraftHelp.create("/gopaint help", commandManager, Source::source); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/injection/ListenerModule.java b/src/main/java/net/onelitefeather/bettergopaint/injection/ListenerModule.java new file mode 100644 index 00000000..f19bdecd --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/injection/ListenerModule.java @@ -0,0 +1,40 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.injection; + +import com.google.inject.AbstractModule; +import com.google.inject.multibindings.Multibinder; +import net.onelitefeather.bettergopaint.listeners.ConnectListener; +import net.onelitefeather.bettergopaint.listeners.InteractListener; +import net.onelitefeather.bettergopaint.listeners.InventoryListener; +import org.bukkit.event.Listener; + +/** + * Collects every listener into a {@code Set} that the plugin registers on enable. + */ +public final class ListenerModule extends AbstractModule { + + @Override + protected void configure() { + Multibinder listeners = Multibinder.newSetBinder(binder(), Listener.class); + listeners.addBinding().to(ConnectListener.class); + listeners.addBinding().to(InteractListener.class); + listeners.addBinding().to(InventoryListener.class); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/injection/PlatformModule.java b/src/main/java/net/onelitefeather/bettergopaint/injection/PlatformModule.java new file mode 100644 index 00000000..333607c8 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/injection/PlatformModule.java @@ -0,0 +1,44 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.injection; + +import com.google.inject.AbstractModule; +import net.kyori.adventure.text.logger.slf4j.ComponentLogger; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; + +/** + * Binds the plugin instance and what the server gives it. + */ +public final class PlatformModule extends AbstractModule { + + private final JavaPlugin plugin; + + public PlatformModule(@NotNull JavaPlugin plugin) { + this.plugin = plugin; + } + + @Override + protected void configure() { + bind(Plugin.class).toInstance(plugin); + bind(JavaPlugin.class).toInstance(plugin); + bind(ComponentLogger.class).toInstance(plugin.getComponentLogger()); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/injection/ServiceModule.java b/src/main/java/net/onelitefeather/bettergopaint/injection/ServiceModule.java new file mode 100644 index 00000000..8dc27542 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/injection/ServiceModule.java @@ -0,0 +1,37 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.injection; + +import com.google.inject.AbstractModule; +import net.onelitefeather.bettergopaint.service.MetricsService; +import net.onelitefeather.bettergopaint.service.TranslationService; +import net.onelitefeather.bettergopaint.service.UpdateService; + +/** + * Binds the long-lived services. + */ +public final class ServiceModule extends AbstractModule { + + @Override + protected void configure() { + bind(UpdateService.class); + bind(TranslationService.class); + bind(MetricsService.class); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/injection/SettingsModule.java b/src/main/java/net/onelitefeather/bettergopaint/injection/SettingsModule.java new file mode 100644 index 00000000..dec37810 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/injection/SettingsModule.java @@ -0,0 +1,46 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.injection; + +import com.google.inject.AbstractModule; +import com.google.inject.name.Names; +import net.onelitefeather.bettergopaint.service.TranslationService; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import org.jetbrains.annotations.NotNull; + +import java.nio.file.Path; + +/** + * Binds the files the plugin reads from its data folder. + */ +public final class SettingsModule extends AbstractModule { + + private final Path dataFolder; + + public SettingsModule(@NotNull Path dataFolder) { + this.dataFolder = dataFolder; + } + + @Override + protected void configure() { + bind(Path.class).annotatedWith(Names.named(SettingsHolder.CONFIG_FILE)).toInstance(dataFolder.resolve("config.yml")); + bind(Path.class).annotatedWith(Names.named(TranslationService.LANG_FOLDER)).toInstance(dataFolder.resolve("lang")); + bind(SettingsHolder.class); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/listeners/ConnectListener.java b/src/main/java/net/onelitefeather/bettergopaint/listeners/ConnectListener.java index 81d9049c..f8583625 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/listeners/ConnectListener.java +++ b/src/main/java/net/onelitefeather/bettergopaint/listeners/ConnectListener.java @@ -18,12 +18,14 @@ */ package net.onelitefeather.bettergopaint.listeners; +import jakarta.inject.Inject; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.onelitefeather.bettergopaint.BetterGoPaint; import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; -import net.onelitefeather.bettergopaint.objects.other.Settings; +import net.onelitefeather.bettergopaint.service.UpdateService; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; import net.onelitefeather.bettergopaint.utils.Constants; +import net.onelitefeather.bettergopaint.utils.Messages; +import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -31,14 +33,21 @@ import org.bukkit.event.player.PlayerQuitEvent; import org.jetbrains.annotations.NotNull; -public class ConnectListener implements Listener { +public final class ConnectListener implements Listener { private final PlayerBrushManager brushManager; - private final BetterGoPaint betterGoPaint; + private final UpdateService updateService; + private final SettingsHolder settings; - public ConnectListener(@NotNull PlayerBrushManager brushManager, final BetterGoPaint paint) { + @Inject + public ConnectListener( + @NotNull PlayerBrushManager brushManager, + @NotNull UpdateService updateService, + @NotNull SettingsHolder settings + ) { this.brushManager = brushManager; - this.betterGoPaint = paint; + this.updateService = updateService; + this.settings = settings; } @EventHandler(priority = EventPriority.LOWEST) @@ -48,13 +57,13 @@ public void onQuit(@NotNull PlayerQuitEvent event) { @EventHandler(priority = EventPriority.LOWEST) public void onJoin(@NotNull PlayerJoinEvent event) { - var player = event.getPlayer(); - if (player.isOp() || (player.hasPermission(Constants.ADMIN_PERMISSION) && !player.hasPermission(Constants.DISABLE_DONATION_NOTIFY))) { - player.sendMessage(Component.translatable("bettergopaint.notify.donation.player").arguments(MiniMessage.miniMessage() - .deserialize(Settings.settings().generic.PREFIX))); + Player player = event.getPlayer(); + if (player.isOp() || (player.hasPermission(Constants.ADMIN_PERMISSION) + && !player.hasPermission(Constants.DISABLE_DONATION_NOTIFY))) { + player.sendMessage(Component.translatable("bettergopaint.notify.donation.player", Messages.prefix(settings.get()))); } if (player.isOp() || player.hasPermission(Constants.PERMISSION_NOTIFY_UPDATE)) { - this.betterGoPaint.getUpdateService().notifyPlayer(player); + updateService.notifyPlayer(player); } } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/listeners/InteractListener.java b/src/main/java/net/onelitefeather/bettergopaint/listeners/InteractListener.java index 1176f58c..68f7296b 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/listeners/InteractListener.java +++ b/src/main/java/net/onelitefeather/bettergopaint/listeners/InteractListener.java @@ -18,15 +18,23 @@ */ package net.onelitefeather.bettergopaint.listeners; +import com.sk89q.worldedit.LocalSession; +import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.bukkit.BukkitAdapter; -import net.kyori.adventure.text.TextComponent; -import net.onelitefeather.bettergopaint.BetterGoPaint; +import com.sk89q.worldedit.bukkit.BukkitPlayer; +import net.kyori.adventure.text.Component; +import jakarta.inject.Inject; import net.onelitefeather.bettergopaint.brush.BrushSettings; -import net.onelitefeather.bettergopaint.brush.ExportedPlayerBrush; +import net.onelitefeather.bettergopaint.brush.ExportedBrushes; import net.onelitefeather.bettergopaint.brush.PlayerBrush; +import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.brush.StrokeContextFactory; +import net.onelitefeather.bettergopaint.brush.StrokeRunner; import net.onelitefeather.bettergopaint.objects.brush.Brush; -import net.onelitefeather.bettergopaint.objects.other.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; import net.onelitefeather.bettergopaint.utils.Constants; +import net.onelitefeather.bettergopaint.utils.Messages; import org.bukkit.FluidCollisionMode; import org.bukkit.Location; import org.bukkit.block.Block; @@ -37,16 +45,20 @@ import org.bukkit.event.block.Action; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; +import org.jetbrains.annotations.NotNull; import java.util.Optional; +import java.util.Random; public final class InteractListener implements Listener { - private final BetterGoPaint plugin; + private final PlayerBrushManager brushManager; + private final SettingsHolder settings; - public InteractListener(BetterGoPaint plugin) { - this.plugin = plugin; + @Inject + public InteractListener(@NotNull PlayerBrushManager brushManager, @NotNull SettingsHolder settings) { + this.brushManager = brushManager; + this.settings = settings; } @EventHandler(priority = EventPriority.LOWEST) @@ -62,8 +74,8 @@ public void onClick(PlayerInteractEvent event) { return; } - if (event.getAction().isLeftClick() && item.getType() == Settings.settings().generic.DEFAULT_BRUSH) { - PlayerBrush brush = plugin.getBrushManager().getBrush(player); + if (event.getAction().isLeftClick() && item.getType() == settings.get().generic().defaultBrush()) { + PlayerBrush brush = brushManager.getBrush(player); player.openInventory(brush.getInventory()); event.setCancelled(true); return; @@ -88,23 +100,21 @@ public void onClick(PlayerInteractEvent event) { final boolean hasNotWorldByePassPermission = !player.hasPermission(Constants.WORLD_BYPASS_PERMISSION); - if (hasNotWorldByePassPermission && Settings.settings().generic.DISABLED_WORLDS + if (hasNotWorldByePassPermission && settings.get().generic().disabledWorlds() .contains(location.getWorld().getName())) { return; } BrushSettings brushSettings; - ItemMeta itemMeta = item.getItemMeta(); - - if (itemMeta != null && itemMeta.hasLore() && itemMeta.displayName() instanceof TextComponent name) { - - Optional brush = plugin.getBrushManager().getBrushHandler(name.content()); - - //noinspection removal - brushSettings = brush.map(current -> ExportedPlayerBrush.parse(current, itemMeta)).orElse(null); - } else if (item.getType().equals(Settings.settings().generic.DEFAULT_BRUSH)) { - brushSettings = plugin.getBrushManager().getBrush(player); + Optional exported = ExportedBrushes.read(item, brushManager, settings.get()); + if (exported.isPresent()) { + brushSettings = exported.get().brush(); + if (exported.get().converted() && event.getHand() != null) { + player.getInventory().setItem(event.getHand(), item); + } + } else if (item.getType().equals(settings.get().generic().defaultBrush())) { + brushSettings = brushManager.getBrush(player); } else { return; } @@ -114,14 +124,35 @@ public void onClick(PlayerInteractEvent event) { } if (brushSettings.enabled()) { - BukkitAdapter.adapt(player).runAction( - () -> brushSettings.brush().paint(location, player, brushSettings), false, true - ); + paint(player, location, brushSettings); } else { - player.sendRichMessage( - Settings.settings().generic.PREFIX + "Your brush is disabled, left click to enable the brush." - ); + player.sendMessage(Component.translatable("bettergopaint.brush.disabled", Messages.prefix(settings.get()))); } } + /** + * Captures everything the stroke needs on the player's thread and queues the stroke. The queued + * work only touches WorldEdit types. + */ + private void paint(@NotNull Player player, @NotNull Location target, @NotNull BrushSettings brushSettings) { + BukkitPlayer actor = BukkitAdapter.adapt(player); + LocalSession localSession = WorldEdit.getInstance().getSessionManager().get(actor); + Brush brush = brushSettings.brush(); + + StrokeContext captured = StrokeContextFactory.capture( + target, + player.getLocation(), + brushSettings, + BukkitAdapter::asBlockType, + new Random(brushSettings.random().nextLong()) + ); + StrokeContext context = brush.prepare(player.getUniqueId(), player.isSneaking(), player, captured); + if (context == null) { + return; + } + + com.sk89q.worldedit.command.tool.brush.Brush stroke = brush.bind(context); + actor.runAction(() -> StrokeRunner.run(actor, localSession, stroke, context), false, true); + } + } diff --git a/src/main/java/net/onelitefeather/bettergopaint/listeners/InventoryListener.java b/src/main/java/net/onelitefeather/bettergopaint/listeners/InventoryListener.java index b7d4b3be..9b5d64f5 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/listeners/InventoryListener.java +++ b/src/main/java/net/onelitefeather/bettergopaint/listeners/InventoryListener.java @@ -18,9 +18,11 @@ */ package net.onelitefeather.bettergopaint.listeners; -import net.kyori.adventure.text.TextComponent; +import jakarta.inject.Inject; import net.onelitefeather.bettergopaint.brush.PlayerBrush; import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; +import net.onelitefeather.bettergopaint.menu.BrushMenuHolder; +import net.onelitefeather.bettergopaint.menu.BrushPickerHolder; import net.onelitefeather.bettergopaint.objects.brush.AngleBrush; import net.onelitefeather.bettergopaint.objects.brush.Brush; import net.onelitefeather.bettergopaint.objects.brush.DiscBrush; @@ -31,9 +33,8 @@ import net.onelitefeather.bettergopaint.objects.brush.SplatterBrush; import net.onelitefeather.bettergopaint.objects.brush.SprayBrush; import net.onelitefeather.bettergopaint.objects.brush.UnderlayBrush; -import net.onelitefeather.bettergopaint.objects.other.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; import net.onelitefeather.bettergopaint.utils.GUI; -import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -41,14 +42,18 @@ import org.bukkit.event.inventory.ClickType; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.inventory.meta.ItemMeta; +import org.jetbrains.annotations.NotNull; public final class InventoryListener implements Listener { private final PlayerBrushManager brushManager; - public InventoryListener(PlayerBrushManager brushManager) { + private final SettingsHolder settings; + + @Inject + public InventoryListener(@NotNull PlayerBrushManager brushManager, @NotNull SettingsHolder settings) { this.brushManager = brushManager; + this.settings = settings; } @EventHandler(priority = EventPriority.LOWEST) @@ -56,7 +61,7 @@ public void menuClick(InventoryClickEvent event) { if (!(event.getWhoClicked() instanceof Player player)) { return; } - if (!(event.getView().title() instanceof TextComponent text) || !text.content().equals("goPaint Menu")) { + if (!(event.getView().getTopInventory().getHolder(false) instanceof BrushMenuHolder)) { return; } if (event.getView().getTopInventory() != event.getClickedInventory()) { @@ -69,7 +74,7 @@ public void menuClick(InventoryClickEvent event) { if (event.getRawSlot() == 10 || event.getRawSlot() == 1 || event.getRawSlot() == 19) { if (event.getClick().equals(ClickType.LEFT)) { if (!event.getCursor().getType().isBlock()) { - if (!event.getCursor().getType().equals(Settings.settings().generic.DEFAULT_BRUSH)) { + if (!event.getCursor().getType().equals(settings.get().generic().defaultBrush())) { playerBrush.export(event.getCursor()); } } @@ -83,7 +88,7 @@ public void menuClick(InventoryClickEvent event) { } else if (event.getClick().equals(ClickType.RIGHT)) { playerBrush.cycleBrushBackwards(); } else if (event.getClick().isShiftClick()) { - player.openInventory(GUI.generateBrushes()); + player.openInventory(GUI.generateBrushes(brushManager.getBrushes(), player.locale())); } event.setCancelled(true); } else if (event.getRawSlot() == 12 || event.getRawSlot() == 3 || event.getRawSlot() == 21) { @@ -193,7 +198,7 @@ public void menuBrushClick(InventoryClickEvent event) { if (!(event.getWhoClicked() instanceof Player player)) { return; } - if (!(event.getView().title() instanceof TextComponent title) || !title.content().equals("goPaint Brushes")) { + if (!(event.getView().getTopInventory().getHolder(false) instanceof BrushPickerHolder)) { return; } @@ -206,25 +211,15 @@ public void menuBrushClick(InventoryClickEvent event) { event.setCancelled(true); - if (event.getCurrentItem() == null || !event.getCurrentItem().getType().equals(Material.PLAYER_HEAD)) { + int slot = event.getRawSlot(); + if (slot < 0 || slot >= brushManager.getBrushes().size()) { return; } PlayerBrush playerBrush = brushManager.getBrush(player); - - ItemMeta itemMeta = event.getCurrentItem().getItemMeta(); - - if (itemMeta == null || !itemMeta.hasDisplayName()) { - return; - } - - //noinspection deprecation - String name = itemMeta.getDisplayName().replace("§6", ""); - brushManager.getBrushHandler(name).ifPresent(brush -> { - playerBrush.setBrush(brush); - playerBrush.updateInventory(); - player.openInventory(playerBrush.getInventory()); - }); + playerBrush.setBrush(brushManager.getBrushes().get(slot)); + playerBrush.updateInventory(); + player.openInventory(playerBrush.getInventory()); } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/menu/BrushMenuHolder.java b/src/main/java/net/onelitefeather/bettergopaint/menu/BrushMenuHolder.java new file mode 100644 index 00000000..1d279446 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/menu/BrushMenuHolder.java @@ -0,0 +1,45 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.menu; + +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.jetbrains.annotations.NotNull; + +/** + * Owns the brush menu of one player. Clicks are handled only in inventories held by this type, + * never by comparing the title. + */ +public final class BrushMenuHolder implements InventoryHolder { + + public static final int SIZE = 54; + + private final Inventory inventory; + + public BrushMenuHolder(@NotNull Component title) { + this.inventory = Bukkit.createInventory(this, SIZE, title); + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/menu/BrushPickerHolder.java b/src/main/java/net/onelitefeather/bettergopaint/menu/BrushPickerHolder.java new file mode 100644 index 00000000..bfcada16 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/menu/BrushPickerHolder.java @@ -0,0 +1,44 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.menu; + +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.jetbrains.annotations.NotNull; + +/** + * Owns the brush picker. Slot {@code n} shows brush {@code n} of the brush list. + */ +public final class BrushPickerHolder implements InventoryHolder { + + public static final int SIZE = 27; + + private final Inventory inventory; + + public BrushPickerHolder(@NotNull Component title) { + this.inventory = Bukkit.createInventory(this, SIZE, title); + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/AngleBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/AngleBrush.java index 378a4ba6..82265d63 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/AngleBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/AngleBrush.java @@ -18,16 +18,13 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Height; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class AngleBrush extends Brush { private static final String NAME = "Angle Brush"; @@ -39,19 +36,12 @@ public AngleBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .filter(block -> Height.getAverageHeightDiffAngle(block.getLocation(), 1) >= 0.1 - && Height.getAverageHeightDiffAngle(block.getLocation(), brushSettings.angleDistance()) - >= Math.tan(Math.toRadians(brushSettings.angleHeightDifference()))) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .filter(position -> Height.getAverageHeightDiffAngle(extent, position, 1) >= 0.1 + && Height.getAverageHeightDiffAngle(extent, position, context.angleDistance()) + >= Math.tan(Math.toRadians(context.angleHeightDifference()))) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/Brush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/Brush.java index 70001ccc..91801ff9 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/Brush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/Brush.java @@ -19,22 +19,29 @@ package net.onelitefeather.bettergopaint.objects.brush; import com.sk89q.worldedit.EditSession; -import com.sk89q.worldedit.LocalSession; import com.sk89q.worldedit.MaxChangedBlocksException; -import com.sk89q.worldedit.WorldEdit; -import com.sk89q.worldedit.bukkit.BukkitAdapter; -import com.sk89q.worldedit.bukkit.BukkitPlayer; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.extent.MaskingExtent; +import com.sk89q.worldedit.function.mask.Mask; +import com.sk89q.worldedit.function.pattern.Pattern; import com.sk89q.worldedit.math.BlockVector3; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.world.block.BlockType; +import net.kyori.adventure.audience.Audience; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Surface; -import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -import java.util.function.Consumer; +import java.util.List; +import java.util.UUID; +/** + * A BetterGoPaint brush. A brush instance only describes the brush. The state of one stroke lives in a + * {@link StrokeContext}, and the brush reads and writes blocks only through the {@link Extent} it is given. + *

+ * {@link #bind(StrokeContext)} turns a brush and a context into WorldEdit's own + * {@link com.sk89q.worldedit.command.tool.brush.Brush}. + */ public abstract class Brush { private final String name; @@ -75,89 +82,134 @@ protected Brush(@NotNull String name, @NotNull String description, @NotNull Stri } /** - * Performs a painting action using the provided location, player, and brush settings. + * Called on the player's thread for every click before a stroke is queued. Most brushes paint on + * every click and return the context unchanged. A brush that needs several clicks collects them here. * - * @param location The location the painting action is performed. - * @param player The player who is performing the paint action. - * @param brushSettings The brush settings to be applied while painting. + * @param playerId the unique id of the player + * @param sneaking whether the player is sneaking + * @param feedback where messages for the player go + * @param context the context captured for this click + * @return the context of the stroke to queue, or null if no stroke should run yet */ - public abstract void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ); + public @Nullable StrokeContext prepare( + @NotNull UUID playerId, + boolean sneaking, + @NotNull Audience feedback, + @NotNull StrokeContext context + ) { + return context; + } /** - * Sets the material of a block in an EditSession. + * Computes the stroke and writes it to the extent. Every read happens before the first block is + * written, so the result does not depend on the order of the writes. * - * @param session The EditSession to perform the block update in. - * @param block The block to update. - * @param material The material to set the block to. - * @throws MaxChangedBlocksException If the maximum number of changed blocks is exceeded. + * @param extent the extent to read from and write to + * @param context the context of the stroke */ - protected void setBlock( - @NotNull EditSession session, - @NotNull Block block, - @NotNull Material material - ) throws MaxChangedBlocksException { - BlockVector3 vector = BlockVector3.at(block.getX(), block.getY(), block.getZ()); - if (session.getMask() == null || session.getMask().test(vector)) { - session.setBlock(vector, BukkitAdapter.asBlockType(material)); - } + public abstract void apply(@NotNull Extent extent, @NotNull StrokeContext context); + + /** + * Binds this brush to the context of one stroke. + * + * @param context the context of the stroke + * @return the stroke as a WorldEdit brush + */ + public final @NotNull com.sk89q.worldedit.command.tool.brush.Brush bind(@NotNull StrokeContext context) { + return new Stroke(this, context); + } + + /** + * Wraps the extent so that no block the mask rejects is written. A stroke runs against the edit session + * of the player, which already carries the global mask. This makes the rule hold for any extent. + * + * @param extent the extent to write to + * @param mask the mask of the player's session, or null + * @return the extent the brush should use + */ + static @NotNull Extent masked(@NotNull Extent extent, @Nullable Mask mask) { + return mask == null ? extent : new MaskingExtent(extent, mask); + } + + /** + * Writes a block of the palette to the position. + * + * @param extent the extent to write to + * @param context the context of the stroke + * @param position the position to change + */ + protected void place(@NotNull Extent extent, @NotNull StrokeContext context, @NotNull BlockVector3 position) { + extent.setBlock(position, context.palette().applyBlock(position)); } /** - * Performs an edit using WorldEdit's EditSession. - * This method wraps the edit session in a try-with-resources block to ensure proper cleanup of resources. + * Writes a block of the palette to every position. * - * @param player The player performing the edit. - * @param edit A Consumer functional interface that defines the actions to be taken within the edit session. + * @param extent the extent to write to + * @param context the context of the stroke + * @param positions the positions to change */ - protected void performEdit(@NotNull Player player, @NotNull Consumer edit) { - BukkitPlayer wrapped = BukkitAdapter.adapt(player); - LocalSession localSession = WorldEdit.getInstance().getSessionManager().get(wrapped); - try (EditSession editsession = localSession.createEditSession(wrapped)) { - try { - edit.accept(editsession); - } finally { - localSession.remember(editsession); - } + protected void placeAll(@NotNull Extent extent, @NotNull StrokeContext context, @NotNull List positions) { + for (BlockVector3 position : positions) { + place(extent, context, position); } } /** - * Checks if a given block passes the default checks defined by the brush settings. + * Writes the default state of a block type to the position. * - * @param brushSettings The brush settings to be checked against. - * @param player The player using the brush. - * @param block The block being checked. - * @return true if the block passes all the default checks, false otherwise. + * @param extent the extent to write to + * @param type the block type to place + * @param position the position to change */ - protected boolean passesDefaultChecks(@NotNull BrushSettings brushSettings, @NotNull Player player, @NotNull Block block) { - return passesMaskCheck(brushSettings, block) && passesSurfaceCheck(brushSettings, player, block); + protected void place(@NotNull Extent extent, @NotNull BlockType type, @NotNull BlockVector3 position) { + extent.setBlock(position, type.getDefaultState()); } /** - * Checks if a given block passes the surface check defined by the brush settings. + * Checks if a position passes the default checks defined by the stroke context. * - * @param brushSettings The brush settings to be checked against. - * @param player The player using the brush. - * @param block The block being checked. - * @return true if the block passes the surface check, false otherwise. + * @param extent the extent to read from + * @param context the context of the stroke + * @param position the position being checked + * @return true if the position passes all the default checks, false otherwise */ - protected boolean passesSurfaceCheck(@NotNull BrushSettings brushSettings, @NotNull Player player, @NotNull Block block) { - return Surface.isOnSurface(block, brushSettings.surfaceMode(), player.getLocation()); + protected boolean passesDefaultChecks(@NotNull Extent extent, @NotNull StrokeContext context, + @NotNull BlockVector3 position) { + return context.passesMask(extent, position) && passesSurfaceCheck(extent, context, position); } /** - * Checks if a given block passes the mask check defined by the brush settings. + * Checks if a position passes the surface check defined by the stroke context. * - * @param brushSettings The brush settings to be checked against. - * @param block The block being checked. - * @return true if the block passes the mask check, false otherwise. + * @param extent the extent to read from + * @param context the context of the stroke + * @param position the position being checked + * @return true if the position passes the surface check, false otherwise */ - protected boolean passesMaskCheck(@NotNull BrushSettings brushSettings, @NotNull Block block) { - return !brushSettings.maskEnabled() || block.getType().equals(brushSettings.mask()); + protected boolean passesSurfaceCheck(@NotNull Extent extent, @NotNull StrokeContext context, + @NotNull BlockVector3 position) { + return Surface.isOnSurface(extent, position, context.surfaceMode(), context.eye()); + } + + /** + * The stroke of a brush as WorldEdit sees it. + */ + private static final class Stroke implements com.sk89q.worldedit.command.tool.brush.Brush { + + private final Brush brush; + private final StrokeContext context; + + private Stroke(@NotNull Brush brush, @NotNull StrokeContext context) { + this.brush = brush; + this.context = context; + } + + @Override + public void build(EditSession session, BlockVector3 position, Pattern pattern, double size) + throws MaxChangedBlocksException { + brush.apply(masked(session, session.getMask()), context.with(position, pattern, (int) Math.round(size))); + } } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/BucketBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/BucketBrush.java index 7be9e819..628842a9 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/BucketBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/BucketBrush.java @@ -18,16 +18,14 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.ConnectedBlocks; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import java.util.List; -import java.util.stream.Stream; public class BucketBrush extends Brush { @@ -40,17 +38,11 @@ public BucketBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - List blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false).toList(); - Stream connectedBlocks = ConnectedBlocks.getConnectedBlocks(location, blocks); - connectedBlocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + List blocks = Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .toList(); + placeAll(extent, context, ConnectedBlocks.getConnectedBlocks(extent, context.target(), blocks) + .filter(position -> passesDefaultChecks(extent, context, position)) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/DiscBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/DiscBrush.java index 138b265e..e6fc2a2b 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/DiscBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/DiscBrush.java @@ -18,15 +18,12 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class DiscBrush extends Brush { private static final String NAME = "Disc Brush"; @@ -38,16 +35,9 @@ public DiscBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), brushSettings.axis(), false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), context.axis(), false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/FractureBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/FractureBrush.java index 6819958c..25846139 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/FractureBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/FractureBrush.java @@ -18,16 +18,13 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Height; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class FractureBrush extends Brush { private static final String NAME = "Fracture Brush"; @@ -39,26 +36,21 @@ public FractureBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesMaskCheck(brushSettings, block)) - .filter(block -> Height.getAverageHeightDiffFracture( - block.getLocation(), - Height.getNearestNonEmptyBlock(block.getLocation()), - 1 - ) >= 0.1) - .filter(block -> Height.getAverageHeightDiffFracture( - block.getLocation(), - Height.getNearestNonEmptyBlock(block.getLocation()), - brushSettings.fractureDistance() - ) >= 0.1) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> context.passesMask(extent, position)) + .filter(position -> Height.getAverageHeightDiffFracture( + extent, + position, + Height.getNearestNonEmptyBlock(extent, position), + 1 + ) >= 0.1) + .filter(position -> Height.getAverageHeightDiffFracture( + extent, + position, + Height.getNearestNonEmptyBlock(extent, position), + context.fractureDistance() + ) >= 0.1) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/GradientBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/GradientBrush.java index f9851a7c..d10fe78f 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/GradientBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/GradientBrush.java @@ -18,14 +18,15 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; +import java.util.ArrayList; +import java.util.List; public class GradientBrush extends Brush { @@ -38,31 +39,30 @@ public GradientBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - double y = location.getBlockY() - (brushSettings.size() / 2d); - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)).filter(block -> { - double rate = (block.getLocation().distance(location) - (brushSettings.size() / 2d) - * ((100d - brushSettings.falloffStrength()) / 100d)) - / ((brushSettings.size() / 2d) - (brushSettings.size() / 2d) - * ((100d - brushSettings.falloffStrength()) / 100d)); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + double y = context.target().y() - (context.size() / 2d); + List blocks = context.blocks(); + List positions = Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .filter(position -> { + double rate = (position.distance(context.target()) - (context.size() / 2d) + * ((100d - context.falloffStrength()) / 100d)) + / ((context.size() / 2d) - (context.size() / 2d) + * ((100d - context.falloffStrength()) / 100d)); - return brushSettings.random().nextDouble() > rate; - }).forEach(block -> { - int random = (int) (((block.getLocation().getBlockY() - y) / brushSettings.size() - * brushSettings.blocks().size()) + (brushSettings.random().nextDouble() * 2 - 1) - * (brushSettings.mixingStrength() / 100d)); + return context.random().nextDouble() > rate; + }) + .toList(); + List types = new ArrayList<>(positions.size()); + for (BlockVector3 position : positions) { + int random = (int) (((position.y() - y) / context.size() + * blocks.size()) + (context.random().nextDouble() * 2 - 1) + * (context.mixingStrength() / 100d)); - int index = Math.clamp(random, 0, brushSettings.blocks().size() - 1); - - setBlock(session, block, brushSettings.blocks().get(index)); - }); - }); + types.add(blocks.get(Math.clamp(random, 0, blocks.size() - 1))); + } + for (int i = 0; i < positions.size(); i++) { + place(extent, types.get(i), positions.get(i)); + } } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/OverlayBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/OverlayBrush.java index c944675a..d55d989d 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/OverlayBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/OverlayBrush.java @@ -18,16 +18,13 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.utils.Blocks; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class OverlayBrush extends Brush { private static final String NAME = "Overlay Brush"; @@ -39,26 +36,19 @@ public OverlayBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesMaskCheck(brushSettings, block)) - .filter(block -> isOverlay(block, brushSettings.thickness())) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> context.passesMask(extent, position)) + .filter(position -> isOverlay(extent, position, context.thickness())) + .toList()); } - private boolean isOverlay(Block block, int thickness) { + private boolean isOverlay(Extent extent, BlockVector3 position, int thickness) { for (int i = 1; i <= thickness; i++) { - if (block.isSolid() && !block.getRelative(BlockFace.UP, i).isSolid()) { + if (Blocks.isSolid(extent, position) && !Blocks.isSolid(extent, position.add(0, i, 0))) { return true; } } return false; } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java index 6cc6d11e..aaa427fb 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrush.java @@ -18,22 +18,27 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; -import net.onelitefeather.bettergopaint.objects.other.Settings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import net.onelitefeather.bettergopaint.utils.Blocks; import net.onelitefeather.bettergopaint.utils.Height; +import net.onelitefeather.bettergopaint.utils.Messages; import net.onelitefeather.bettergopaint.utils.Sphere; import net.onelitefeather.bettergopaint.utils.curve.BezierSpline; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.UUID; -import java.util.stream.Stream; +import java.util.concurrent.ConcurrentHashMap; public class PaintBrush extends Brush { @@ -41,70 +46,92 @@ public class PaintBrush extends Brush { private static final String DESCRIPTION = "Paints strokes\n§8hold shift to end"; private static final String HEAD = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvODBiM2E5ZGZhYmVmYmRkOTQ5YjIxN2JiZDRmYTlhNDg2YmQwYzNmMGNhYjBkMGI5ZGZhMjRjMzMyZGQzZTM0MiJ9fX0="; - public PaintBrush() { + /** + * Points of the stroke each player is currently drawing. Owned by this brush instance, so it is dropped with + * the player's brush state through {@link #forget(UUID)}. + */ + private final Map> selectedPoints = new ConcurrentHashMap<>(); + + private final SettingsHolder settings; + + public PaintBrush(@NotNull SettingsHolder settings) { super(NAME, DESCRIPTION, HEAD); + this.settings = settings; } - private static final HashMap> selectedPoints = new HashMap<>(); + /** + * Drops the unfinished stroke of a player, for example when the player quits. + * + * @param playerId the player whose stroke points should be discarded + */ + public void forget(@NotNull UUID playerId) { + selectedPoints.remove(playerId); + } @Override - public void paint( - @NotNull Location target, - @NotNull Player player, - @NotNull BrushSettings brushSettings + public @Nullable StrokeContext prepare( + @NotNull UUID playerId, + boolean sneaking, + @NotNull Audience feedback, + @NotNull StrokeContext context ) { - String prefix = Settings.settings().generic.PREFIX; - - List locations = selectedPoints.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()); - locations.add(target); - - if (!player.isSneaking()) { - player.sendRichMessage(prefix + " Paint brush point #" + locations.size() + " set."); - return; + List points = selectedPoints.computeIfAbsent(playerId, ignored -> new ArrayList<>()); + points.add(context.target()); + + if (!sneaking) { + feedback.sendMessage(Component.translatable( + "bettergopaint.brush.paint.point", + Messages.prefix(settings.get()), + Component.text(points.size()) + )); + return null; } - selectedPoints.remove(player.getUniqueId()); - - performEdit(player, session -> { - Location first = locations.getFirst(); - Stream blocks = Sphere.getBlocksInRadius(first, brushSettings.size(), null, false); - blocks.forEach(block -> { - if (Height.getAverageHeightDiffAngle(block.getLocation(), 1) >= 0.1 - && Height.getAverageHeightDiffAngle(block.getLocation(), brushSettings.angleDistance()) - >= Math.tan(Math.toRadians(brushSettings.angleHeightDifference()))) { - return; - } - - double rate = (block.getLocation().distance(first) - (brushSettings.size() / 2.0) - * ((100.0 - brushSettings.falloffStrength()) / 100.0)) / ((brushSettings.size() / 2.0) - - (brushSettings.size() / 2.0) * ((100.0 - brushSettings.falloffStrength()) / 100.0)); - - if (brushSettings.random().nextDouble() <= rate) { - return; - } + selectedPoints.remove(playerId); + return context.withPoints(points); + } - LinkedList newCurve = new LinkedList<>(); - newCurve.add(block.getLocation()); - for (Location location : locations) { - newCurve.add(block.getLocation().clone().add( - location.getX() - first.getX(), - location.getY() - first.getY(), - location.getZ() - first.getZ() - )); + @Override + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + List points = context.points(); + BlockVector3 first = points.getFirst(); + List positions = new ArrayList<>(); + + Sphere.getBlocksInRadius(extent, first, context.size(), null, false).forEach(block -> { + if (Height.getAverageHeightDiffAngle(extent, block, 1) >= 0.1 + && Height.getAverageHeightDiffAngle(extent, block, context.angleDistance()) + >= Math.tan(Math.toRadians(context.angleHeightDifference()))) { + return; + } + + double rate = (block.distance(first) - (context.size() / 2.0) + * ((100.0 - context.falloffStrength()) / 100.0)) / ((context.size() / 2.0) + - (context.size() / 2.0) * ((100.0 - context.falloffStrength()) / 100.0)); + + if (context.random().nextDouble() <= rate) { + return; + } + + LinkedList newCurve = new LinkedList<>(); + newCurve.add(block.toVector3()); + for (BlockVector3 point : points) { + newCurve.add(block.add(point.subtract(first)).toVector3()); + } + BezierSpline spline = new BezierSpline(newCurve); + double maxCount = (spline.getCurveLength() * 2.5) + 1; + for (int y = 0; y <= maxCount; y++) { + Vector3 curvePoint = spline.getPoint((y / maxCount) * (points.size() - 1)); + BlockVector3 point = BlockVector3.at(curvePoint.x(), curvePoint.y(), curvePoint.z()); + + if (Blocks.isAir(extent, point) || !passesDefaultChecks(extent, context, point)) { + continue; } - BezierSpline spline = new BezierSpline(newCurve); - double maxCount = (spline.getCurveLength() * 2.5) + 1; - for (int y = 0; y <= maxCount; y++) { - Block point = spline.getPoint((y / maxCount) * (locations.size() - 1)).getBlock(); - if (point.isEmpty() || !passesDefaultChecks(brushSettings, player, point)) { - continue; - } - - setBlock(session, point, brushSettings.randomBlock()); - } - }); + positions.add(point); + } }); + + placeAll(extent, context, positions); } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SphereBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SphereBrush.java index e23d4e1e..324f2e3c 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SphereBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SphereBrush.java @@ -18,15 +18,12 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class SphereBrush extends Brush { private static final String NAME = "Sphere Brush"; @@ -38,16 +35,9 @@ public SphereBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SplatterBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SplatterBrush.java index 8803a488..d7a7017a 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SplatterBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SplatterBrush.java @@ -18,15 +18,12 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class SplatterBrush extends Brush { private static final String NAME = "Splatter Brush"; @@ -38,27 +35,17 @@ public SplatterBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .forEach(block -> { - double rate = (block.getLocation().distance(location) - ((double) brushSettings.size() / 2.0) - * ((100.0 - (double) brushSettings.falloffStrength()) / 100.0)) - / (((double) brushSettings.size() / 2.0) - ((double) brushSettings.size() / 2.0) - * ((100.0 - (double) brushSettings.falloffStrength()) / 100.0)); - - if (brushSettings.random().nextDouble() <= rate) { - return; - } - - setBlock(session, block, brushSettings.randomBlock()); - }); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .filter(position -> { + double rate = (position.distance(context.target()) - ((double) context.size() / 2.0) + * ((100.0 - (double) context.falloffStrength()) / 100.0)) + / (((double) context.size() / 2.0) - ((double) context.size() / 2.0) + * ((100.0 - (double) context.falloffStrength()) / 100.0)); + + return !(context.random().nextDouble() <= rate); + }) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SprayBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SprayBrush.java index e9fb5c3c..ccedd02c 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SprayBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/SprayBrush.java @@ -18,15 +18,12 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class SprayBrush extends Brush { private static final String NAME = "Spray Brush"; @@ -38,17 +35,10 @@ public SprayBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesDefaultChecks(brushSettings, player, block)) - .filter(block -> brushSettings.random().nextInt(100) < brushSettings.chance()) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> passesDefaultChecks(extent, context, position)) + .filter(position -> context.random().nextInt(100) < context.chance()) + .toList()); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/UnderlayBrush.java b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/UnderlayBrush.java index ecf24c07..e2325c43 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/brush/UnderlayBrush.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/brush/UnderlayBrush.java @@ -18,16 +18,13 @@ */ package net.onelitefeather.bettergopaint.objects.brush; -import net.onelitefeather.bettergopaint.brush.BrushSettings; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.utils.Blocks; import net.onelitefeather.bettergopaint.utils.Sphere; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import java.util.stream.Stream; - public class UnderlayBrush extends Brush { private static final String NAME = "Underlay Brush"; @@ -39,26 +36,19 @@ public UnderlayBrush() { } @Override - public void paint( - @NotNull Location location, - @NotNull Player player, - @NotNull BrushSettings brushSettings - ) { - performEdit(player, session -> { - Stream blocks = Sphere.getBlocksInRadius(location, brushSettings.size(), null, false); - blocks.filter(block -> passesMaskCheck(brushSettings, block)) - .filter(block -> isUnderlay(block, brushSettings.thickness())) - .forEach(block -> setBlock(session, block, brushSettings.randomBlock())); - }); + public void apply(@NotNull Extent extent, @NotNull StrokeContext context) { + placeAll(extent, context, Sphere.getBlocksInRadius(extent, context.target(), context.size(), null, false) + .filter(position -> context.passesMask(extent, position)) + .filter(position -> isUnderlay(extent, position, context.thickness())) + .toList()); } - private boolean isUnderlay(Block block, int thickness) { + private boolean isUnderlay(Extent extent, BlockVector3 position, int thickness) { for (int i = 1; i <= thickness; i++) { - if (!block.isSolid() || !block.getRelative(BlockFace.UP, i).isSolid()) { + if (!Blocks.isSolid(extent, position) || !Blocks.isSolid(extent, position.add(0, i, 0))) { return false; } } return true; } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/other/Settings.java b/src/main/java/net/onelitefeather/bettergopaint/objects/other/Settings.java deleted file mode 100644 index 71e975bc..00000000 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/other/Settings.java +++ /dev/null @@ -1,135 +0,0 @@ -/* - * goPaint is designed to simplify painting inside of Minecraft. - * Copyright (C) Arcaniax-Development - * Copyright (C) Arcaniax team and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package net.onelitefeather.bettergopaint.objects.other; - -import com.fastasyncworldedit.core.configuration.Config; -import net.onelitefeather.bettergopaint.BetterGoPaint; -import org.bukkit.Axis; -import org.bukkit.Material; - -import java.util.ArrayList; -import java.util.List; - -public final class Settings extends Config { - - @Ignore - private static final Settings settings = new Settings(); - - @Create - public Generic generic; - - @Create - public Thickness thickness; - - @Create - public Angle angle; - - @Create - public Fracture fracture; - - @Comment("This is related to generic settings") - public static class Generic { - - @Comment("Supported Languages") - public List LANGUAGES = new ArrayList<>(); - - @Comment({ - "Default brush item", - "Possible values: " + BetterGoPaint.PAPER_DOCS, - "Only items are allowed" - }) - public Material DEFAULT_BRUSH = Material.FEATHER; - @Comment("Max size of the brush") - public int MAX_SIZE = 100; - @Comment("Default size for each player of the brush") - public int DEFAULT_SIZE = 10; - @Comment("Default chance for some brushes") - public int DEFAULT_CHANCE = 50; - @Comment({"Default axis for disc brush", "Possible values: X, Y, Z"}) - public Axis DEFAULT_AXIS = Axis.Y; - @Comment("Default paint falloff strength") - public int DEFAULT_FALLOFF_STRENGTH = 50; - @Comment("Default paint mixing strength") - public int DEFAULT_MIXING_STRENGTH = 50; - @Comment("Prefix of the plugin") - public String PREFIX = "BetterGoPaint > "; - @Comment("World there are disabled to used brushes") - public List DISABLED_WORLDS = new ArrayList<>(); - - @Comment("Enables BetterGoPaint usage by default") - public boolean ENABLED_BY_DEFAULT = true; - - @Comment("Enables mask usage by default") - public boolean MASK_ENABLED = true; - - @Comment({ - "Default mask to apply", - "Possible values: " + BetterGoPaint.PAPER_DOCS - }) - public Material DEFAULT_MASK = Material.SPONGE; - - @Comment({"Direct surface mode usage by default", "Possible values: DIRECT, DISABLED, RELATIVE"}) - public SurfaceMode SURFACE_MODE = SurfaceMode.DIRECT; - - } - - @Comment("This is related to thickness settings") - public static class Thickness { - - @Comment("Default thickness for some brushes") - public int DEFAULT_THICKNESS = 1; - - @Comment("Maximum thickness for some brushes") - public int MAX_THICKNESS = 5; - - } - - @Comment("This is related to angle settings") - public static class Angle { - - @Comment("Default angle distance for some brushes") - public int DEFAULT_ANGLE_DISTANCE = 2; - @Comment("Maximum angle distance for some brushes") - public int MAX_ANGLE_DISTANCE = 5; - - @Comment("Minimum angle height difference for some brushes") - public double MIN_ANGLE_HEIGHT_DIFFERENCE = 10.0; - - @Comment("Default angle height difference for some brushes") - public double DEFAULT_ANGLE_HEIGHT_DIFFERENCE = 40.0; - - public double MAX_ANGLE_HEIGHT_DIFFERENCE = 85.0; - - } - - @Comment("This is related to fracture settings") - public static class Fracture { - - @Comment("Default fracture for some brushes") - public int DEFAULT_FRACTURE_DISTANCE = 2; - @Comment("Maximum fracture for some brushes") - public int MAX_FRACTURE_DISTANCE = 5; - - } - - public static Settings settings() { - return settings; - } - -} diff --git a/src/main/java/net/onelitefeather/bettergopaint/objects/other/SurfaceMode.java b/src/main/java/net/onelitefeather/bettergopaint/objects/other/SurfaceMode.java index 8d0e3749..778bec26 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/objects/other/SurfaceMode.java +++ b/src/main/java/net/onelitefeather/bettergopaint/objects/other/SurfaceMode.java @@ -19,8 +19,9 @@ package net.onelitefeather.bettergopaint.objects.other; import net.onelitefeather.bettergopaint.utils.Surface; -import org.bukkit.Location; -import org.bukkit.block.Block; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; import org.jetbrains.annotations.NotNull; import java.util.Optional; @@ -29,19 +30,19 @@ public enum SurfaceMode { /** * This enumeration represents a more intuitive check. * - * @see Surface#isDirectlyOnSurface(Block) + * @see Surface#isDirectlyOnSurface(Extent, BlockVector3) */ DIRECT("Direct"), /** * This enumeration represents that surface mode is disabled. * - * @see Surface#isOnSurface(Block, SurfaceMode, Location) + * @see Surface#isOnSurface(Extent, BlockVector3, SurfaceMode, Vector3) */ DISABLED("Disabled"), /** * This enumeration represents the original surface mode check. * - * @see Surface#isRelativelyOnSurface(Block, Location) + * @see Surface#isRelativelyOnSurface(Extent, BlockVector3, Vector3) */ RELATIVE("Relative"); diff --git a/src/main/java/net/onelitefeather/bettergopaint/service/HangarReleaseSource.java b/src/main/java/net/onelitefeather/bettergopaint/service/HangarReleaseSource.java new file mode 100644 index 00000000..a306b655 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/service/HangarReleaseSource.java @@ -0,0 +1,73 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Asks Hangar for the version string of the latest BetterGoPaint release. + * Every failure is reported as an empty result, never as an exception. + */ +public final class HangarReleaseSource implements Supplier>, AutoCloseable { + + private static final Logger LOGGER = LoggerFactory.getLogger(HangarReleaseSource.class); + private static final URI LATEST_RELEASE_URI = + URI.create("https://hangar.papermc.io/api/v1/projects/BetterGoPaint/latestrelease"); + private static final HttpRequest LATEST_RELEASE_REQUEST = HttpRequest.newBuilder(LATEST_RELEASE_URI) + .timeout(Duration.ofSeconds(10)) + .GET() + .build(); + + private final HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + @Override + public Optional get() { + try { + HttpResponse response = client.send(LATEST_RELEASE_REQUEST, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + LOGGER.debug("Update check: Hangar answered with status {}", response.statusCode()); + return Optional.empty(); + } + return Optional.of(response.body()); + } catch (IOException e) { + LOGGER.debug("Update check: Hangar is not reachable ({})", e.getMessage()); + return Optional.empty(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Optional.empty(); + } + } + + @Override + public void close() { + client.shutdownNow(); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/service/MetricsService.java b/src/main/java/net/onelitefeather/bettergopaint/service/MetricsService.java new file mode 100644 index 00000000..f0dc280c --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/service/MetricsService.java @@ -0,0 +1,61 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.service; + +import com.fastasyncworldedit.core.Fawe; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.bstats.bukkit.Metrics; +import org.bstats.charts.SimplePie; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/** + * Sends anonymous usage statistics to bStats. + */ +@Singleton +public final class MetricsService { + + private static final int BSTATS_ID = 18734; + + private final JavaPlugin plugin; + private @Nullable Metrics metrics; + + @Inject + public MetricsService(@NotNull JavaPlugin plugin) { + this.plugin = plugin; + } + + public void start() { + metrics = new Metrics(plugin, BSTATS_ID); + metrics.addCustomChart(new SimplePie( + "faweVersion", + () -> Objects.requireNonNull(Fawe.instance().getVersion()).toString() + )); + } + + public void shutdown() { + if (metrics != null) { + metrics.shutdown(); + } + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/service/TranslationService.java b/src/main/java/net/onelitefeather/bettergopaint/service/TranslationService.java new file mode 100644 index 00000000..dc149b3e --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/service/TranslationService.java @@ -0,0 +1,79 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.service; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Singleton; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.minimessage.translation.MiniMessageTranslationStore; +import net.kyori.adventure.translation.GlobalTranslator; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Locale; +import java.util.ResourceBundle; + +/** + * Registers the bundled English messages and every extra language from {@code lang/} with + * Adventure's global translator. + */ +@Singleton +public final class TranslationService { + + public static final String LANG_FOLDER = "lang-folder"; + private static final String BUNDLE = "bettergopaint"; + + private final Path langFolder; + private final SettingsHolder settings; + private final ClassLoader pluginClassLoader; + + @Inject + public TranslationService(@Named(LANG_FOLDER) @NotNull Path langFolder, @NotNull SettingsHolder settings) { + this.langFolder = langFolder; + this.settings = settings; + this.pluginClassLoader = TranslationService.class.getClassLoader(); + } + + public void register() { + var store = MiniMessageTranslationStore.create(Key.key("bettergopaint", "translations")); + store.defaultLocale(Locale.US); + var languages = new HashSet<>(settings.get().generic().languages()); + languages.add("en-US"); + if (Files.exists(langFolder)) { + try (var classLoader = new URLClassLoader(new URL[]{langFolder.toUri().toURL()}, pluginClassLoader)) { + languages.stream().map(Locale::forLanguageTag).forEach(locale -> + store.registerAll(locale, ResourceBundle.getBundle(BUNDLE, locale, classLoader), false)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } else { + languages.stream().map(Locale::forLanguageTag).forEach(locale -> + store.registerAll(locale, ResourceBundle.getBundle(BUNDLE, locale, pluginClassLoader), false)); + } + GlobalTranslator.translator().addSource(store); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/service/UpdateService.java b/src/main/java/net/onelitefeather/bettergopaint/service/UpdateService.java index d508e2f4..5976637c 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/service/UpdateService.java +++ b/src/main/java/net/onelitefeather/bettergopaint/service/UpdateService.java @@ -19,94 +19,138 @@ package net.onelitefeather.bettergopaint.service; import com.github.zafarkhaja.semver.Version; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.logger.slf4j.ComponentLogger; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.onelitefeather.bettergopaint.BetterGoPaint; -import net.onelitefeather.bettergopaint.objects.other.Settings; -import net.onelitefeather.bettergopaint.utils.Constants; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import net.onelitefeather.bettergopaint.utils.Messages; import org.bukkit.Bukkit; import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitTask; +import org.bukkit.plugin.Plugin; +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.net.http.HttpClient; -import java.net.http.HttpResponse; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** + * Checks Hangar for a newer release off the main thread and tells the console and joining admins about it. + */ +@Singleton +public final class UpdateService { -public final class UpdateService implements Runnable { - private final HttpClient hangarClient = HttpClient.newBuilder().build(); private static final Logger LOGGER = LoggerFactory.getLogger(UpdateService.class); + private static final long CHECK_INTERVAL_HOURS = 3L; + private static final String DOWNLOAD_URL = "https://hangar.papermc.io/OneLiteFeather/BetterGoPaint/versions/%s"; + + private final Plugin plugin; + private final SettingsHolder settings; + private final ComponentLogger console; private final Version localVersion; - private Version remoteVersion; - private final BukkitTask scheduler; - private final String DOWNLOAD_URL = "https://hangar.papermc.io/OneLiteFeather/BetterGoPaint/versions/%s"; + private final Supplier> latestReleaseSource; + + private volatile @Nullable Version remoteVersion; + private @Nullable ScheduledTask task; - public UpdateService(BetterGoPaint betterGoPaint) { - this.localVersion = Version.parse(betterGoPaint.getPluginMeta().getVersion()); - this.scheduler = Bukkit.getScheduler().runTaskTimerAsynchronously(betterGoPaint, this, 0, 20 * 60 * 60 * 3); + @Inject + public UpdateService(@NotNull Plugin plugin, @NotNull ComponentLogger console, @NotNull SettingsHolder settings) { + this(plugin, console, settings, Version.parse(plugin.getPluginMeta().getVersion()), new HangarReleaseSource()); } + UpdateService( + @NotNull Plugin plugin, + @NotNull ComponentLogger console, + @NotNull SettingsHolder settings, + @NotNull Version localVersion, + @NotNull Supplier> latestReleaseSource + ) { + this.plugin = plugin; + this.console = console; + this.settings = settings; + this.localVersion = localVersion; + this.latestReleaseSource = latestReleaseSource; + } - @Override - public void run() { - var remoteVersion = getNewerVersion(); - if (remoteVersion != null) { - this.remoteVersion = remoteVersion; - for (Player onlinePlayer : Bukkit.getOnlinePlayers()) { - if (onlinePlayer.isOp() || onlinePlayer.hasPermission(Constants.PERMISSION_NOTIFY_UPDATE)) { - notifyPlayer(localVersion, remoteVersion, onlinePlayer); - } - } + /** + * Schedules the periodic check on Paper's async scheduler, starting immediately. The check runs off the + * server thread, which also keeps it valid on Folia. Does nothing when {@code generic.check-for-updates} + * is disabled. + */ + public void start() { + if (!settings.get().generic().checkForUpdates()) { + return; } + this.task = Bukkit.getAsyncScheduler().runAtFixedRate( + plugin, + scheduled -> check(), + 0, + CHECK_INTERVAL_HOURS, + TimeUnit.HOURS + ); } - public void notifyConsole(ComponentLogger logger) { - if (this.remoteVersion != null && this.remoteVersion.isHigherThan(this.localVersion)) { - logger.warn(Component.translatable("bettergopaint.notify.update.console") - .arguments(Component.text(localVersion.toString()), - Component.text(remoteVersion.toString()), - Component.text(DOWNLOAD_URL.formatted(remoteVersion.toString())) - )); + /** + * Runs one check. The console is told once per newly discovered version. + */ + void check() { + Optional newer = latestReleaseSource.get().flatMap(this::parseNewer); + if (newer.isEmpty() || newer.get().equals(remoteVersion)) { + return; } + Version version = newer.get(); + this.remoteVersion = version; + console.warn(Component.translatable("bettergopaint.notify.update.console").arguments( + Component.text(localVersion.toString()), + Component.text(version.toString()), + Component.text(DOWNLOAD_URL.formatted(version)) + )); } - - public void notifyPlayer(Player player) { - if (this.remoteVersion != null && this.remoteVersion.isHigherThan(this.localVersion)) { - notifyPlayer(this.localVersion, this.remoteVersion, player); + private Optional parseNewer(String body) { + try { + Version version = Version.parse(body.trim()); + return version.isHigherThan(localVersion) ? Optional.of(version) : Optional.empty(); + } catch (RuntimeException e) { + LOGGER.debug("Update check: Hangar answered with something that is not a version: {}", body); + return Optional.empty(); } } - - private void notifyPlayer(Version localVersion, Version remoteVersion, Player player) { - player.sendMessage(Component.translatable("bettergopaint.notify.update.player") - .arguments( - MiniMessage.miniMessage().deserialize(Settings.settings().generic.PREFIX), - Component.text(localVersion.toString()), - Component.text(remoteVersion.toString()) - )); + /** + * @return the newest release known to be higher than the installed version + */ + public Optional remoteVersion() { + return Optional.ofNullable(remoteVersion); } - - @Nullable - private Version getNewerVersion() { - try { - HttpResponse httpResponse = hangarClient.send(Constants.LATEST_RELEASE_VERSION_REQUEST, HttpResponse.BodyHandlers.ofString()); - Version remoteVersion = Version.parse(httpResponse.body()); - if (remoteVersion.isHigherThan(this.localVersion)) { - return remoteVersion; - } - } catch (IOException | InterruptedException e) { - LOGGER.error("Something went wrong to check updates", e); + public void notifyPlayer(@NotNull Player player) { + Version version = this.remoteVersion; + if (version == null) { + return; } - return null; + player.sendMessage(Component.translatable("bettergopaint.notify.update.player").arguments( + Messages.prefix(settings.get()), + Component.text(localVersion.toString()), + Component.text(version.toString()) + )); } public void shutdown() { - this.hangarClient.shutdownNow(); - this.scheduler.cancel(); + if (task != null) { + task.cancel(); + task = null; + } + if (latestReleaseSource instanceof AutoCloseable closeable) { + try { + closeable.close(); + } catch (Exception e) { + LOGGER.debug("Update check: closing the HTTP client failed", e); + } + } } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/settings/Settings.java b/src/main/java/net/onelitefeather/bettergopaint/settings/Settings.java new file mode 100644 index 00000000..9cca20fe --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/settings/Settings.java @@ -0,0 +1,89 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.settings; + +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import org.bukkit.Axis; +import org.bukkit.Material; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * An immutable snapshot of {@code config.yml}. A reload publishes a new snapshot; consumers read + * the current one through {@link SettingsHolder#get()} each time they need a value. + */ +public record Settings( + @NotNull Generic generic, + @NotNull Thickness thickness, + @NotNull Angle angle, + @NotNull Fracture fracture +) { + + public static final String PAPER_MATERIALS = "https://jd.papermc.io/paper/org/bukkit/Material.html"; + + public static @NotNull Settings defaults() { + return new Settings( + new Generic(List.of(), Material.FEATHER, 100, 10, 50, Axis.Y, 50, 50, + "BetterGoPaint > ", true, List.of(), true, true, Material.SPONGE, SurfaceMode.DIRECT), + new Thickness(1, 5), + new Angle(2, 5, 10.0, 40.0, 85.0), + new Fracture(2, 5) + ); + } + + public record Generic( + @NotNull List languages, + @NotNull Material defaultBrush, + int maxSize, + int defaultSize, + int defaultChance, + @NotNull Axis defaultAxis, + int defaultFalloffStrength, + int defaultMixingStrength, + @NotNull String prefix, + boolean checkForUpdates, + @NotNull List disabledWorlds, + boolean enabledByDefault, + boolean maskEnabled, + @NotNull Material defaultMask, + @NotNull SurfaceMode surfaceMode + ) { + + public Generic { + languages = List.copyOf(languages); + disabledWorlds = List.copyOf(disabledWorlds); + } + } + + public record Thickness(int defaultThickness, int maxThickness) { + } + + public record Angle( + int defaultAngleDistance, + int maxAngleDistance, + double minAngleHeightDifference, + double defaultAngleHeightDifference, + double maxAngleHeightDifference + ) { + } + + public record Fracture(int defaultFractureDistance, int maxFractureDistance) { + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/settings/SettingsHolder.java b/src/main/java/net/onelitefeather/bettergopaint/settings/SettingsHolder.java new file mode 100644 index 00000000..99662caa --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/settings/SettingsHolder.java @@ -0,0 +1,72 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.settings; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Provider; +import jakarta.inject.Singleton; +import org.jetbrains.annotations.NotNull; + +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Holds the current {@link Settings} snapshot and swaps it atomically on reload. + */ +@Singleton +public final class SettingsHolder implements Provider { + + public static final String CONFIG_FILE = "config-file"; + + private final Path file; + private final AtomicReference current; + + @Inject + public SettingsHolder(@Named(CONFIG_FILE) @NotNull Path file) { + this.file = file; + this.current = new AtomicReference<>(SettingsLoader.load(file)); + } + + private SettingsHolder(@NotNull Settings settings) { + this.file = Path.of("config.yml"); + this.current = new AtomicReference<>(settings); + } + + /** + * A holder that never touches the disk, for tests. + */ + public static @NotNull SettingsHolder of(@NotNull Settings settings) { + return new SettingsHolder(settings); + } + + @Override + public @NotNull Settings get() { + return current.get(); + } + + /** + * Re-reads {@code config.yml} and publishes the new snapshot. + */ + public @NotNull Settings reload() { + Settings settings = SettingsLoader.load(file); + current.set(settings); + return settings; + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/settings/SettingsLoader.java b/src/main/java/net/onelitefeather/bettergopaint/settings/SettingsLoader.java new file mode 100644 index 00000000..f277e25f --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/settings/SettingsLoader.java @@ -0,0 +1,170 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.settings; + +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import org.bukkit.Axis; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.function.Function; + +/** + * Reads {@code config.yml} into {@link Settings} and writes it back with every key and its comment, + * so new keys appear after an update. A missing or invalid value is replaced by its default and + * reported once in the log. + */ +public final class SettingsLoader { + + private static final Logger LOGGER = LoggerFactory.getLogger(SettingsLoader.class); + + private SettingsLoader() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + public static @NotNull Settings load(@NotNull Path file) { + YamlConfiguration yaml = Files.exists(file) ? YamlConfiguration.loadConfiguration(file.toFile()) : new YamlConfiguration(); + Settings defaults = Settings.defaults(); + Reader reader = new Reader(yaml); + + Settings.Generic g = defaults.generic(); + Settings.Generic generic = new Settings.Generic( + reader.list("generic.languages", g.languages(), "Extra languages to load from lang/, for example de-DE"), + reader.material("generic.default-brush", g.defaultBrush(), false, "Item that acts as the brush. Must be an item: " + Settings.PAPER_MATERIALS), + reader.integer("generic.max-size", g.maxSize(), 1, "Largest brush size a player can set"), + reader.integer("generic.default-size", g.defaultSize(), 1, "Brush size of a new player"), + reader.integer("generic.default-chance", g.defaultChance(), 0, "Starting chance of the spray brush in percent"), + reader.enumValue("generic.default-axis", Axis.class, g.defaultAxis(), "Starting axis of the disc brush: X, Y or Z"), + reader.integer("generic.default-falloff-strength", g.defaultFalloffStrength(), 0, "Starting falloff strength"), + reader.integer("generic.default-mixing-strength", g.defaultMixingStrength(), 0, "Starting mixing strength of the gradient brush"), + reader.string("generic.prefix", g.prefix(), "MiniMessage prefix of chat messages"), + reader.bool("generic.check-for-updates", g.checkForUpdates(), "Check Hangar every three hours for a newer release and tell admins about it"), + reader.list("generic.disabled-worlds", g.disabledWorlds(), "Worlds in which painting is blocked"), + reader.bool("generic.enabled-by-default", g.enabledByDefault(), "Whether the brush of a new player starts enabled"), + reader.bool("generic.mask-enabled", g.maskEnabled(), "Whether the mask starts enabled"), + reader.material("generic.default-mask", g.defaultMask(), false, "Starting mask block: " + Settings.PAPER_MATERIALS), + reader.enumValue("generic.surface-mode", SurfaceMode.class, g.surfaceMode(), "Starting surface mode: DIRECT, RELATIVE or DISABLED") + ); + Settings.Thickness t = defaults.thickness(); + Settings.Thickness thickness = new Settings.Thickness( + reader.integer("thickness.default-thickness", t.defaultThickness(), 1, "Starting thickness of the overlay and underlay brush"), + reader.integer("thickness.max-thickness", t.maxThickness(), 1, "Largest thickness") + ); + Settings.Angle a = defaults.angle(); + Settings.Angle angle = new Settings.Angle( + reader.integer("angle.default-angle-distance", a.defaultAngleDistance(), 1, "Starting angle check distance"), + reader.integer("angle.max-angle-distance", a.maxAngleDistance(), 1, "Largest angle check distance"), + reader.decimal("angle.min-angle-height-difference", a.minAngleHeightDifference(), "Smallest angle height difference"), + reader.decimal("angle.default-angle-height-difference", a.defaultAngleHeightDifference(), "Starting angle height difference"), + reader.decimal("angle.max-angle-height-difference", a.maxAngleHeightDifference(), "Largest angle height difference") + ); + Settings.Fracture f = defaults.fracture(); + Settings.Fracture fracture = new Settings.Fracture( + reader.integer("fracture.default-fracture-distance", f.defaultFractureDistance(), 1, "Starting fracture check distance"), + reader.integer("fracture.max-fracture-distance", f.maxFractureDistance(), 1, "Largest fracture check distance") + ); + + try { + Files.createDirectories(file.toAbsolutePath().getParent()); + yaml.save(file.toFile()); + } catch (IOException e) { + throw new UncheckedIOException("Cannot write " + file, e); + } + return new Settings(generic, thickness, angle, fracture); + } + + /** + * Reads one key at a time, writes the effective value back and attaches the comment. + */ + private record Reader(YamlConfiguration yaml) { + + private T read(String path, T fallback, String comment, Function parse, Function write) { + T value = fallback; + Object raw = yaml.get(path); + if (raw != null) { + T parsed = null; + try { + parsed = parse.apply(raw); + } catch (RuntimeException ignored) { + // reported below + } + if (parsed == null) { + LOGGER.warn("config.yml: {} has the invalid value '{}', using {}", path, raw, fallback); + } else { + value = parsed; + } + } + yaml.set(path, write.apply(value)); + yaml.setComments(path, List.of(comment)); + return value; + } + + int integer(String path, int fallback, int min, String comment) { + return read(path, fallback, comment, raw -> { + int value = raw instanceof Number number ? number.intValue() : Integer.parseInt(raw.toString().trim()); + return value >= min ? value : null; + }, value -> value); + } + + double decimal(String path, double fallback, String comment) { + return read(path, fallback, comment, raw -> raw instanceof Number number + ? number.doubleValue() + : Double.parseDouble(raw.toString().trim()), value -> value); + } + + boolean bool(String path, boolean fallback, String comment) { + return read(path, fallback, comment, raw -> switch (raw.toString().trim().toLowerCase(Locale.ROOT)) { + case "true" -> Boolean.TRUE; + case "false" -> Boolean.FALSE; + default -> null; + }, value -> value); + } + + String string(String path, String fallback, String comment) { + return read(path, fallback, comment, Object::toString, value -> value); + } + + List list(String path, List fallback, String comment) { + return read(path, fallback, comment, raw -> raw instanceof List list + ? list.stream().map(Object::toString).toList() + : null, value -> value); + } + + > E enumValue(String path, Class type, E fallback, String comment) { + return read(path, fallback, comment, + raw -> Enum.valueOf(type, raw.toString().trim().toUpperCase(Locale.ROOT)), Enum::name); + } + + Material material(String path, Material fallback, boolean item, String comment) { + return read(path, fallback, comment, raw -> { + Material material = Material.matchMaterial(raw.toString().trim()); + return material == null || (item && !material.isItem()) ? null : material; + }, Enum::name); + } + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/translations/PluginTranslationRegistry.java b/src/main/java/net/onelitefeather/bettergopaint/translations/PluginTranslationRegistry.java deleted file mode 100644 index cd7038e5..00000000 --- a/src/main/java/net/onelitefeather/bettergopaint/translations/PluginTranslationRegistry.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * goPaint is designed to simplify painting inside of Minecraft. - * Copyright (C) Arcaniax-Development - * Copyright (C) Arcaniax team and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package net.onelitefeather.bettergopaint.translations; - -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.ComponentLike; -import net.kyori.adventure.text.TranslatableComponent; -import net.kyori.adventure.text.minimessage.Context; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.minimessage.ParsingException; -import net.kyori.adventure.text.minimessage.tag.Tag; -import net.kyori.adventure.text.minimessage.tag.resolver.ArgumentQueue; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; -import net.kyori.adventure.translation.TranslationRegistry; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.text.MessageFormat; -import java.util.List; -import java.util.Locale; -import java.util.Objects; - -public final class PluginTranslationRegistry implements TranslationRegistry { - - private final TranslationRegistry backedRegistry; - - public PluginTranslationRegistry(TranslationRegistry backedRegistry) { - this.backedRegistry = backedRegistry; - } - - @Override - public boolean contains(@NotNull String key) { - return backedRegistry.contains(key); - } - - @Override - public @NotNull Key name() { - return backedRegistry.name(); - } - - @Override - public @Nullable MessageFormat translate(@NotNull String key, @NotNull Locale locale) { - return null; - } - - @Override - public @Nullable Component translate( - @NotNull TranslatableComponent component, - @NotNull Locale locale - ) { - final MessageFormat translationFormat = backedRegistry.translate(component.key(), locale); - - if (translationFormat == null) { - return null; - } - - final String miniMessageString = translationFormat.toPattern(); - - final Component resultingComponent; - - if (component.arguments().isEmpty()) { - resultingComponent = MiniMessage.miniMessage().deserialize(miniMessageString); - } else { - resultingComponent = MiniMessage.miniMessage().deserialize(miniMessageString, - new ArgumentTag(component.arguments())); - } - - if (component.children().isEmpty()) { - return resultingComponent; - } else { - return resultingComponent.children(component.children()); - } - } - - @Override - public void defaultLocale(@NotNull Locale locale) { - backedRegistry.defaultLocale(locale); - } - - @Override - public void register(@NotNull String key, @NotNull Locale locale, @NotNull MessageFormat format) { - backedRegistry.register(key, locale, format); - } - - @Override - public void unregister(@NotNull String key) { - backedRegistry.unregister(key); - } - - private static final class ArgumentTag implements TagResolver { - private static final String NAME = "argument"; - private static final String NAME_1 = "arg"; - - private final List argumentComponents; - - private ArgumentTag(final @NotNull List argumentComponents) { - this.argumentComponents = Objects.requireNonNull(argumentComponents, "argumentComponents"); - } - - @Override - public Tag resolve( - final @NotNull String name, - final @NotNull ArgumentQueue arguments, - final @NotNull Context ctx - ) throws ParsingException { - final int index = arguments.popOr("No argument number provided") - .asInt().orElseThrow(() -> ctx.newException("Invalid argument number", arguments)); - - if (index < 0 || index >= argumentComponents.size()) { - throw ctx.newException("Invalid argument number", arguments); - } - - return Tag.inserting(argumentComponents.get(index)); - } - - @Override - public boolean has(final @NotNull String name) { - return name.equals(NAME) || name.equals(NAME_1); - } - } -} diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/Blocks.java b/src/main/java/net/onelitefeather/bettergopaint/utils/Blocks.java new file mode 100644 index 00000000..45d06f3e --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/Blocks.java @@ -0,0 +1,88 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils; + +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockState; +import org.jetbrains.annotations.NotNull; + +/** + * Block queries on a WorldEdit {@link Extent}. Positions outside the build limits of the extent read + * as air, like they did when the Bukkit world was asked. + */ +public final class Blocks { + + private Blocks() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + /** + * Checks if the given height is inside the build limits of the extent. + * + * @param extent the extent to check + * @param y the height to check + * @return true if the height can be read from the extent + */ + public static boolean isInBounds(@NotNull Extent extent, int y) { + return y >= extent.getMinY() && y <= extent.getMaxY(); + } + + /** + * Checks if the block at the given position is air. Positions outside the build limits are air. + * + * @param extent the extent to read from + * @param position the position to check + * @return true if the block is air + */ + public static boolean isAir(@NotNull Extent extent, @NotNull BlockVector3 position) { + return isAir(extent, position.x(), position.y(), position.z()); + } + + /** + * Checks if the block at the given coordinates is air. Positions outside the build limits are air. + * + * @param extent the extent to read from + * @param x the x coordinate + * @param y the y coordinate + * @param z the z coordinate + * @return true if the block is air + */ + public static boolean isAir(@NotNull Extent extent, int x, int y, int z) { + if (!isInBounds(extent, y)) { + return true; + } + BlockState state = extent.getBlock(x, y, z); + return state.getBlockType().getMaterial().isAir(); + } + + /** + * Checks if the block at the given position is solid. Positions outside the build limits are not solid. + * + * @param extent the extent to read from + * @param position the position to check + * @return true if the block is solid + */ + public static boolean isSolid(@NotNull Extent extent, @NotNull BlockVector3 position) { + if (!isInBounds(extent, position.y())) { + return false; + } + return extent.getBlock(position).getBlockType().getMaterial().isSolid(); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocks.java b/src/main/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocks.java index 8927a23a..39224258 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocks.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocks.java @@ -18,14 +18,14 @@ */ package net.onelitefeather.bettergopaint.utils; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; -import java.util.Arrays; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Queue; @@ -34,13 +34,13 @@ public final class ConnectedBlocks { - private static final BlockFace[] faces = new BlockFace[]{ - BlockFace.NORTH, - BlockFace.EAST, - BlockFace.SOUTH, - BlockFace.WEST, - BlockFace.UP, - BlockFace.DOWN, + private static final BlockVector3[] FACES = new BlockVector3[]{ + BlockVector3.at(0, 0, -1), + BlockVector3.at(1, 0, 0), + BlockVector3.at(0, 0, 1), + BlockVector3.at(-1, 0, 0), + BlockVector3.at(0, 1, 0), + BlockVector3.at(0, -1, 0), }; private ConnectedBlocks() { @@ -48,34 +48,37 @@ private ConnectedBlocks() { } /** - * Returns a stream of connected blocks starting from a given location, based on a list of blocks. + * Returns a stream of connected positions starting from a given position, based on a list of positions. * Only blocks of the same type as the start block are considered. * - * @param loc the starting location - * @param blocks the list of blocks to check for connectivity - * @return a stream of connected blocks + * @param extent the extent to read from + * @param start the starting position + * @param blocks the list of positions to check for connectivity + * @return a stream of connected positions */ - @Contract(value = "_, _ -> new", pure = true) - public static @NotNull Stream getConnectedBlocks(@NotNull Location loc, @NotNull List blocks) { + @Contract(value = "_, _, _ -> new", pure = true) + public static @NotNull Stream getConnectedBlocks(@NotNull Extent extent, @NotNull BlockVector3 start, + @NotNull List blocks) { if (blocks.isEmpty()) return Stream.empty(); - Block startBlock = loc.getBlock(); - Set connected = new HashSet<>(); - Queue toCheck = new LinkedList<>(); + BlockType startType = extent.getBlock(start).getBlockType(); + Set candidates = new HashSet<>(blocks); + Set connected = new LinkedHashSet<>(); + Queue toCheck = new LinkedList<>(); - toCheck.add(startBlock); - connected.add(startBlock); + toCheck.add(start); + connected.add(start); while (!toCheck.isEmpty() && connected.size() < blocks.size()) { - Block current = toCheck.poll(); - List neighbors = Arrays.stream(faces) - .map(current::getRelative) - .filter(relative -> relative.getType().equals(startBlock.getType()) - && !connected.contains(relative) - && blocks.contains(relative)) - .toList(); - - connected.addAll(neighbors); - toCheck.addAll(neighbors); + BlockVector3 current = toCheck.poll(); + for (BlockVector3 face : FACES) { + BlockVector3 relative = current.add(face); + if (candidates.contains(relative) + && !connected.contains(relative) + && extent.getBlock(relative).getBlockType().equals(startType)) { + connected.add(relative); + toCheck.add(relative); + } + } } return connected.stream(); diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/Constants.java b/src/main/java/net/onelitefeather/bettergopaint/utils/Constants.java index a183b1a9..ed70bc27 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/Constants.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/Constants.java @@ -18,9 +18,6 @@ */ package net.onelitefeather.bettergopaint.utils; -import java.net.URI; -import java.net.http.HttpRequest; - public final class Constants { public static final String USE_PERMISSION = "bettergopaint.use"; @@ -28,9 +25,10 @@ public final class Constants { public static final String PERMISSION_NOTIFY_UPDATE = "bettergopaint.notify.admin.update"; public static final String DISABLE_DONATION_NOTIFY = "bettergopaint.notify.disable.donation"; public static final String RELOAD_PERMISSION = "bettergopaint.command.admin.reload"; + public static final String HELP_PERMISSION = "bettergopaint.command.help"; + public static final String SIZE_PERMISSION = "bettergopaint.command.size"; + public static final String TOGGLE_PERMISSION = "bettergopaint.command.toggle"; + public static final String INFO_PERMISSION = "bettergopaint.command.info"; public static final String WORLD_BYPASS_PERMISSION = "bettergopaint.world.bypass"; - public static final URI LATEST_RELEASE_VERSION_URI = URI.create("https://hangar.papermc.io/api/v1/projects/BetterGoPaint/latestrelease"); - public static final HttpRequest LATEST_RELEASE_VERSION_REQUEST = HttpRequest.newBuilder().GET().uri(LATEST_RELEASE_VERSION_URI).build(); - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/GUI.java b/src/main/java/net/onelitefeather/bettergopaint/utils/GUI.java index bab8d9dc..8213ec61 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/GUI.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/GUI.java @@ -19,9 +19,13 @@ package net.onelitefeather.bettergopaint.utils; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentLike; import net.kyori.adventure.text.format.NamedTextColor; -import net.onelitefeather.bettergopaint.BetterGoPaint; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.translation.GlobalTranslator; import net.onelitefeather.bettergopaint.brush.PlayerBrush; +import net.onelitefeather.bettergopaint.menu.BrushMenuHolder; +import net.onelitefeather.bettergopaint.menu.BrushPickerHolder; import net.onelitefeather.bettergopaint.objects.brush.AngleBrush; import net.onelitefeather.bettergopaint.objects.brush.Brush; import net.onelitefeather.bettergopaint.objects.brush.DiscBrush; @@ -32,224 +36,212 @@ import net.onelitefeather.bettergopaint.objects.brush.SplatterBrush; import net.onelitefeather.bettergopaint.objects.brush.SprayBrush; import net.onelitefeather.bettergopaint.objects.brush.UnderlayBrush; -import net.onelitefeather.bettergopaint.objects.other.Settings; -import org.bukkit.Bukkit; +import net.onelitefeather.bettergopaint.settings.Settings; import org.bukkit.Material; import org.bukkit.inventory.Inventory; -import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; -public class GUI { +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; - private static final BetterGoPaint plugin = JavaPlugin.getPlugin(BetterGoPaint.class); +/** + * Builds the brush menu and the brush picker. All text comes from the translation bundles and is + * rendered for the locale of the player who owns the menu. + */ +public final class GUI { + + private static final Component SPACER = Component.text(" "); + + private GUI() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } - public static @NotNull Inventory create(PlayerBrush pb) { - Inventory inv = Bukkit.createInventory(null, 54, Component.text("goPaint Menu", NamedTextColor.DARK_BLUE)); - update(inv, pb); - return inv; + public static @NotNull Inventory create(@NotNull PlayerBrush playerBrush) { + Inventory inventory = new BrushMenuHolder(text(playerBrush.locale(), "bettergopaint.menu.title")).getInventory(); + update(inventory, playerBrush); + return inventory; } - public static @NotNull Inventory generateBrushes() { - Inventory inv = Bukkit.createInventory(null, 27, Component.text("goPaint Brushes", NamedTextColor.DARK_BLUE)); - // FILLER - formatDefault(inv); - for (int index = 0; index < plugin.getBrushManager().getBrushes().size(); index++) { - Brush brush = plugin.getBrushManager().getBrushes().get(index); - inv.setItem(index, Items.createHead(brush.getHead(), 1, "§6" + brush.getName(), - "\n§7Click to select\n\n§8" + brush.getDescription() + public static @NotNull Inventory generateBrushes(@NotNull List brushes, @NotNull Locale locale) { + Inventory inventory = new BrushPickerHolder(text(locale, "bettergopaint.menu.picker.title")).getInventory(); + formatDefault(inventory); + for (int index = 0; index < brushes.size() && index < inventory.getSize(); index++) { + Brush brush = brushes.get(index); + List lore = new ArrayList<>(); + lore.add(Component.empty()); + lore.add(text(locale, "bettergopaint.menu.picker.select")); + lore.add(Component.empty()); + lore.addAll(description(brush)); + inventory.setItem(index, Items.createHead( + brush.getHead(), + Component.text(brush.getName(), NamedTextColor.GOLD), + lore )); } - return inv; + return inventory; + } + + /** + * Lists every brush, the selected one highlighted. + */ + public static @NotNull List brushList(@NotNull List brushes, @NotNull Brush selected, @NotNull Locale locale) { + return brushes.stream() + .map(brush -> brush.equals(selected) + ? text(locale, "bettergopaint.menu.type.entry.selected", Component.text(brush.getName())) + : text(locale, "bettergopaint.menu.type.entry", Component.text(brush.getName()))) + .toList(); + } + + private static List description(Brush brush) { + return Arrays.stream(brush.getDescription().split("\n")) + .map(line -> LegacyComponentSerializer.legacySection().deserialize(line) + .colorIfAbsent(NamedTextColor.DARK_GRAY)) + .toList(); + } + + private static Component text(Locale locale, String key, ComponentLike... arguments) { + return GlobalTranslator.render(Component.translatable(key, arguments), locale); + } + + private static List lore(Locale locale, String... keys) { + List lines = new ArrayList<>(); + lines.add(Component.empty()); + for (String key : keys) { + lines.add(text(locale, key)); + } + return lines; + } + + private static ItemStack pane(Material material) { + return Items.create(material, 1, SPACER, List.of()); } private static void formatDefault(@NotNull Inventory inventory) { for (int slot = 0; slot < inventory.getSize(); slot++) { - inventory.setItem(slot, Items.create(Material.GRAY_STAINED_GLASS_PANE, 1, "§7", "")); + inventory.setItem(slot, pane(Material.GRAY_STAINED_GLASS_PANE)); } } + private static void column(Inventory inventory, int top, Material frame, ItemStack item) { + inventory.setItem(top, pane(frame)); + inventory.setItem(top + 9, item); + inventory.setItem(top + 18, pane(frame)); + } + public static void update(@NotNull Inventory inventory, @NotNull PlayerBrush playerBrush) { Brush brush = playerBrush.brush(); + Locale locale = playerBrush.locale(); - // FILLER formatDefault(inventory); - // goPaint toggle - if (playerBrush.enabled()) { - inventory.setItem(1, Items.create(Material.LIME_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(10, Items.create(Settings.settings().generic.DEFAULT_BRUSH, 1, "§6goPaint Brush", - "§a§lEnabled\n\n§7Left click with item to export\n§7Right click to toggle" - )); - inventory.setItem(19, Items.create(Material.LIME_STAINED_GLASS_PANE, 1, "§7", "")); - } else { - inventory.setItem(1, Items.create(Material.RED_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(10, Items.create(Settings.settings().generic.DEFAULT_BRUSH, 1, "§6goPaint Brush", - "§c§lDisabled\n\n§7Left click with item to export\n§7Right click to toggle" - )); - inventory.setItem(19, Items.create(Material.RED_STAINED_GLASS_PANE, 1, "§7", "")); - } - - // Brushes + Chance - inventory.setItem(2, Items.create(Material.ORANGE_STAINED_GLASS_PANE, 1, "§7", "")); + // brush item: export and toggle + List brushLore = new ArrayList<>(); + brushLore.add(text(locale, playerBrush.enabled() ? "bettergopaint.menu.brush.enabled" : "bettergopaint.menu.brush.disabled")); + brushLore.addAll(lore(locale, "bettergopaint.menu.brush.export", "bettergopaint.menu.brush.toggle")); + column(inventory, 1, playerBrush.enabled() ? Material.LIME_STAINED_GLASS_PANE : Material.RED_STAINED_GLASS_PANE, + Items.create(playerBrush.settings().generic().defaultBrush(), 1, text(locale, "bettergopaint.menu.brush.name"), brushLore)); + // brush type + List typeLore = lore(locale, "bettergopaint.menu.type.select", "bettergopaint.menu.type.cycle"); + typeLore.add(Component.empty()); + typeLore.addAll(brushList(playerBrush.brushes(), brush, locale)); + column(inventory, 2, Material.ORANGE_STAINED_GLASS_PANE, + Items.createHead(brush.getHead(), text(locale, "bettergopaint.menu.type.name"), typeLore)); - String clicks = "\n§7Shift click to select\n§7Click to cycle brush\n\n"; + List increaseDecrease = lore(locale, "bettergopaint.menu.click.increase", "bettergopaint.menu.click.decrease"); - inventory.setItem(11, Items.createHead(brush.getHead(), 1, "§6Selected Brush type", - clicks + plugin.getBrushManager().getBrushLore(brush) - )); - inventory.setItem(20, Items.create(Material.ORANGE_STAINED_GLASS_PANE, 1, "§7", "")); - - // chance + // first brush specific setting if (brush instanceof SprayBrush) { - inventory.setItem(3, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(12, Items.create(Material.GOLD_NUGGET, 1, - "§6Place chance: §e" + playerBrush.chance() + "%", - "\n§7Left click to increase\n§7Right click to decrease" - )); - inventory.setItem(21, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); + column(inventory, 3, Material.WHITE_STAINED_GLASS_PANE, Items.create(Material.GOLD_NUGGET, 1, + text(locale, "bettergopaint.menu.chance", Component.text(playerBrush.chance())), increaseDecrease)); } - - // axis if (brush instanceof DiscBrush) { - inventory.setItem(3, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(12, Items.create(Material.COMPASS, 1, - "§6Axis: §e" + playerBrush.axis(), "\n§7Click to change" - )); - inventory.setItem(21, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); + column(inventory, 3, Material.WHITE_STAINED_GLASS_PANE, Items.create(Material.COMPASS, 1, + text(locale, "bettergopaint.menu.axis", Component.text(playerBrush.axis().name())), + lore(locale, "bettergopaint.menu.click.change"))); } - - - // thickness if (brush instanceof OverlayBrush || brush instanceof UnderlayBrush) { - inventory.setItem(3, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(12, Items.create(Material.BOOK, 1, - "§6Layer Thickness: §e" + playerBrush.thickness(), - "\n§7Left click to increase\n§7Right click to decrease" - )); - inventory.setItem(21, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); + column(inventory, 3, Material.WHITE_STAINED_GLASS_PANE, Items.create(Material.BOOK, 1, + text(locale, "bettergopaint.menu.thickness", Component.text(playerBrush.thickness())), increaseDecrease)); } - - // angle settings if (brush instanceof AngleBrush) { - inventory.setItem(3, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(12, Items.create(Material.DAYLIGHT_DETECTOR, 1, - "§6Angle Check Distance: §e" + playerBrush.angleDistance(), - "\n§7Left click to increase\n§7Right click to decrease" - )); - inventory.setItem(21, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); - - inventory.setItem(4, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(13, Items.create(Material.BLAZE_ROD, 1, - "§6Maximum Angle: §e" + playerBrush.angleHeightDifference() + "°", - "\n§7Left click to increase\n§7Right click to decrease\n§7Shift click to change by 15" - )); - inventory.setItem(22, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); + column(inventory, 3, Material.WHITE_STAINED_GLASS_PANE, Items.create(Material.DAYLIGHT_DETECTOR, 1, + text(locale, "bettergopaint.menu.angle_distance", Component.text(playerBrush.angleDistance())), + increaseDecrease)); + column(inventory, 4, Material.WHITE_STAINED_GLASS_PANE, Items.create(Material.BLAZE_ROD, 1, + text(locale, "bettergopaint.menu.angle_height", Component.text(playerBrush.angleHeightDifference())), + lore(locale, "bettergopaint.menu.click.increase", "bettergopaint.menu.click.decrease", + "bettergopaint.menu.click.shift_15"))); } - - // fracture settings if (brush instanceof FractureBrush) { - inventory.setItem(3, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(12, Items.create(Material.DAYLIGHT_DETECTOR, 1, - "§6Fracture Check Distance: §e" + playerBrush.fractureDistance(), - "\n§7Left click to increase\n§7Right click to decrease" - )); - inventory.setItem(21, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); + column(inventory, 3, Material.WHITE_STAINED_GLASS_PANE, Items.create(Material.DAYLIGHT_DETECTOR, 1, + text(locale, "bettergopaint.menu.fracture_distance", Component.text(playerBrush.fractureDistance())), + increaseDecrease)); } - - // angle settings if (brush instanceof GradientBrush) { - inventory.setItem(4, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(13, Items.create(Material.MAGMA_CREAM, 1, - "§6Mixing Strength: §e" + playerBrush.mixingStrength() + "%", - "\n§7Left click to increase\n§7Right click to decrease" - )); - inventory.setItem(22, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); + column(inventory, 4, Material.WHITE_STAINED_GLASS_PANE, Items.create(Material.MAGMA_CREAM, 1, + text(locale, "bettergopaint.menu.mixing", Component.text(playerBrush.mixingStrength())), increaseDecrease)); } - if (brush instanceof SplatterBrush || brush instanceof PaintBrush || brush instanceof GradientBrush) { - inventory.setItem(3, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(12, Items.create(Material.BLAZE_POWDER, 1, - "§6Falloff Strength: §e" + playerBrush.falloffStrength() + "%", - "\n§7Left click to increase\n§7Right click to decrease" - )); - inventory.setItem(21, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); + column(inventory, 3, Material.WHITE_STAINED_GLASS_PANE, Items.create(Material.BLAZE_POWDER, 1, + text(locale, "bettergopaint.menu.falloff", Component.text(playerBrush.falloffStrength())), increaseDecrease)); } - - // Size - inventory.setItem(5, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(14, Items.create(Material.BROWN_MUSHROOM, 1, - "§6Brush Size: §e" + playerBrush.size(), - "\n§7Left click to increase\n§7Right click to decrease\n§7Shift click to change by 10" - )); - inventory.setItem(23, Items.create(Material.WHITE_STAINED_GLASS_PANE, 1, "§7", "")); - - // Mask toggle - if (playerBrush.maskEnabled()) { - inventory.setItem(6, Items.create(Material.LIME_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(15, Items.create(Material.JACK_O_LANTERN, 1, - "§6Mask", - "§a§lEnabled\n\n§7Click to toggle" - )); - inventory.setItem(24, Items.create(Material.LIME_STAINED_GLASS_PANE, 1, "§7", "")); - } else { - inventory.setItem(6, Items.create(Material.RED_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(15, Items.create(Material.CARVED_PUMPKIN, 1, "§6Mask", "§c§lDisabled\n\n§7Click to toggle")); - inventory.setItem(24, Items.create(Material.RED_STAINED_GLASS_PANE, 1, "§7", "")); - } - - // Surface Mode toggle - addSurfaceModeSwitch(inventory, playerBrush); - - // Place Block - for (int x = 37; x <= 41; x++) { - inventory.setItem(x, Items.create(Material.YELLOW_STAINED_GLASS_PANE, 1, "§7", "")); + // size + column(inventory, 5, Material.WHITE_STAINED_GLASS_PANE, Items.create(Material.BROWN_MUSHROOM, 1, + text(locale, "bettergopaint.menu.size", Component.text(playerBrush.size())), + lore(locale, "bettergopaint.menu.click.increase", "bettergopaint.menu.click.decrease", + "bettergopaint.menu.click.shift_10"))); + + // mask toggle + List maskLore = new ArrayList<>(); + maskLore.add(text(locale, playerBrush.maskEnabled() ? "bettergopaint.menu.brush.enabled" : "bettergopaint.menu.brush.disabled")); + maskLore.addAll(lore(locale, "bettergopaint.menu.click.toggle")); + column(inventory, 6, playerBrush.maskEnabled() ? Material.LIME_STAINED_GLASS_PANE : Material.RED_STAINED_GLASS_PANE, + Items.create(playerBrush.maskEnabled() ? Material.JACK_O_LANTERN : Material.CARVED_PUMPKIN, 1, + text(locale, "bettergopaint.menu.mask"), maskLore)); + + // surface mode + Material surfacePane = switch (playerBrush.surfaceMode()) { + case DIRECT -> Material.LIME_STAINED_GLASS_PANE; + case DISABLED -> Material.RED_STAINED_GLASS_PANE; + case RELATIVE -> Material.ORANGE_STAINED_GLASS_PANE; + }; + String surfaceKey = switch (playerBrush.surfaceMode()) { + case DIRECT -> "bettergopaint.menu.surface.direct"; + case DISABLED -> "bettergopaint.menu.surface.disabled"; + case RELATIVE -> "bettergopaint.menu.surface.relative"; + }; + List surfaceLore = new ArrayList<>(); + surfaceLore.add(text(locale, surfaceKey)); + surfaceLore.addAll(lore(locale, "bettergopaint.menu.click.toggle")); + column(inventory, 7, surfacePane, Items.create(Material.LIGHT_WEIGHTED_PRESSURE_PLATE, 1, + text(locale, "bettergopaint.menu.surface"), surfaceLore)); + + // palette + for (int slot = 37; slot <= 41; slot++) { + inventory.setItem(slot, pane(Material.YELLOW_STAINED_GLASS_PANE)); } - for (int x = 46; x <= 50; x++) { - inventory.setItem(x, Items.create(Material.BARRIER, 1, "§cEmpty Slot", "\n§7Click with a block to set")); + for (int slot = 46; slot <= 50; slot++) { + inventory.setItem(slot, Items.create(Material.BARRIER, 1, text(locale, "bettergopaint.menu.slot.empty"), + lore(locale, "bettergopaint.menu.slot.empty.hint"))); } - int x = 46; + int slot = 46; int size = playerBrush.blocks().size(); int chance = size == 0 ? 0 : 100 / size; for (Material material : playerBrush.blocks()) { - if (chance > 64) { - inventory.setItem(x, Items.create(material, 1, - "§aSlot " + (x - 45) + " §7" + chance + "%", - "\n§7Left click with a block to change\n§7Right click to clear" - )); - } else { - inventory.setItem(x, Items.create(material, chance, - "§aSlot " + (x - 45) + " §7" + chance + "%", - "\n§7Left click with a block to change\n§7Right click to clear" - )); - } - x++; + inventory.setItem(slot, Items.create(material, chance > 64 ? 1 : Math.max(1, chance), + text(locale, "bettergopaint.menu.slot", Component.text(slot - 45), Component.text(chance)), + lore(locale, "bettergopaint.menu.slot.change", "bettergopaint.menu.slot.clear"))); + slot++; } - // Mask Block - inventory.setItem(43, Items.create(Material.YELLOW_STAINED_GLASS_PANE, 1, "§7", "")); - inventory.setItem(52, Items.create(playerBrush.mask(), 1, "§6Current Mask", "\n§7Left click with a block to change")); - } - - private static void addSurfaceModeSwitch(Inventory inv, PlayerBrush playerBrush) { - Material pane = switch (playerBrush.surfaceMode()) { - case DIRECT -> Material.LIME_STAINED_GLASS_PANE; - case DISABLED -> Material.RED_STAINED_GLASS_PANE; - case RELATIVE -> Material.ORANGE_STAINED_GLASS_PANE; - }; - String color = switch (playerBrush.surfaceMode()) { - case DIRECT -> "§a"; - case DISABLED -> "§c"; - case RELATIVE -> "§6"; - }; - - inv.setItem(7, Items.create(pane, 1, "§7", "")); - inv.setItem(16, Items.create(Material.LIGHT_WEIGHTED_PRESSURE_PLATE, 1, - "§6Surface Mode", - color + "§l" + playerBrush.surfaceMode().getName() + "\n\n§7Click to toggle" - )); - inv.setItem(25, Items.create(pane, 1, "§7", "")); + // mask block + inventory.setItem(43, pane(Material.YELLOW_STAINED_GLASS_PANE)); + inventory.setItem(52, Items.create(playerBrush.mask(), 1, text(locale, "bettergopaint.menu.mask.current"), + lore(locale, "bettergopaint.menu.mask.change"))); } - } diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/Height.java b/src/main/java/net/onelitefeather/bettergopaint/utils/Height.java index 49b64b00..8a5c4c05 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/Height.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/Height.java @@ -18,8 +18,8 @@ */ package net.onelitefeather.bettergopaint.utils; -import org.bukkit.Location; -import org.bukkit.block.Block; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; import org.jetbrains.annotations.NotNull; public class Height { @@ -29,82 +29,88 @@ private Height() { } /** - * Gets the height of the nearest non-empty block at a given location. + * Gets the height of the nearest non-empty block at a given position. * - * @param location the location to check - * @return the height of the nearest non-empty block at the location + * @param extent the extent to read from + * @param position the position to check + * @return the height of the nearest non-empty block at the position */ - public static int getNearestNonEmptyBlock(@NotNull Location location) { - if (location.getBlock().getType().isEmpty()) { - for (int y = location.getBlockY(); y >= location.getWorld().getMinHeight(); y--) { - Block block = location.getWorld().getBlockAt(location.getBlockX(), y, location.getBlockZ()); - if (!block.isEmpty()) { + public static int getNearestNonEmptyBlock(@NotNull Extent extent, @NotNull BlockVector3 position) { + int minHeight = extent.getMinY(); + int maxHeight = extent.getMaxY() + 1; + int x = position.x(); + int z = position.z(); + if (Blocks.isAir(extent, position)) { + for (int y = position.y(); y >= minHeight; y--) { + if (!Blocks.isAir(extent, x, y, z)) { return y + 1; } } - return location.getWorld().getMinHeight(); + return minHeight; } else { - for (int y = location.getBlockY(); y <= location.getWorld().getMaxHeight(); y++) { - Block block = location.getWorld().getBlockAt(location.getBlockX(), y, location.getBlockZ()); - if (block.isEmpty()) { + for (int y = position.y(); y <= maxHeight; y++) { + if (Blocks.isAir(extent, x, y, z)) { return y; } } - return location.getWorld().getMaxHeight(); + return maxHeight; } } /** - * Calculates the average height difference of the surrounding blocks from a given location, within a specified distance. + * Calculates the average height difference of the surrounding blocks from a given position, within a specified distance. * - * @param location the location to calculate the average height difference from + * @param extent the extent to read from + * @param position the position to calculate the average height difference from * @param height the reference height of the nearest non-empty block * @param distance the distance at which to calculate the average height difference * @return the average height difference of the surrounding blocks within the specified distance */ - public static double getAverageHeightDiffFracture(@NotNull Location location, int height, int distance) { + public static double getAverageHeightDiffFracture(@NotNull Extent extent, @NotNull BlockVector3 position, int height, + int distance) { double totalHeight = 0; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, -distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(-distance, 0, distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(-distance, 0, -distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(0, 0, -distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(0, 0, distance))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(-distance, 0, 0))) - height; - totalHeight += Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, 0))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, -distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(-distance, 0, distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(-distance, 0, -distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(0, 0, -distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(0, 0, distance))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(-distance, 0, 0))) - height; + totalHeight += Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, 0))) - height; return (totalHeight / 8d) / distance; } /** - * Calculates the average height difference angle of the surrounding blocks from a given location within a specified distance. + * Calculates the average height difference angle of the surrounding blocks from a given position within a specified distance. * - * @param location the location to calculate the average height difference angle from + * @param extent the extent to read from + * @param position the position to calculate the average height difference angle from * @param distance the distance at which to calculate the average height difference angle * @return the average height difference angle of the surrounding blocks within the specified distance */ - public static double getAverageHeightDiffAngle(@NotNull Location location, int distance) { + public static double getAverageHeightDiffAngle(@NotNull Extent extent, @NotNull BlockVector3 position, int distance) { double maxHeightDiff = 0; double maxHeightDiff2 = 0; - double diff = Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, -distance)) - - getNearestNonEmptyBlock(location.clone().add(-distance, 0, distance))); + double diff = Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, -distance)) + - getNearestNonEmptyBlock(extent, position.add(-distance, 0, distance))); if (diff >= maxHeightDiff) { maxHeightDiff = diff; maxHeightDiff2 = maxHeightDiff; } - diff = Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, distance)) - - getNearestNonEmptyBlock(location.clone().add(-distance, 0, -distance))); + diff = Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, distance)) + - getNearestNonEmptyBlock(extent, position.add(-distance, 0, -distance))); if (diff > maxHeightDiff) { maxHeightDiff = diff; maxHeightDiff2 = maxHeightDiff; } - diff = Math.abs(getNearestNonEmptyBlock(location.clone().add(distance, 0, 0)) - - getNearestNonEmptyBlock(location.clone().add(-distance, 0, 0))); + diff = Math.abs(getNearestNonEmptyBlock(extent, position.add(distance, 0, 0)) + - getNearestNonEmptyBlock(extent, position.add(-distance, 0, 0))); if (diff > maxHeightDiff) { maxHeightDiff = diff; maxHeightDiff2 = maxHeightDiff; } - diff = Math.abs(getNearestNonEmptyBlock(location.clone().add(0, 0, -distance)) - - getNearestNonEmptyBlock(location.clone().add(0, 0, distance))); + diff = Math.abs(getNearestNonEmptyBlock(extent, position.add(0, 0, -distance)) + - getNearestNonEmptyBlock(extent, position.add(0, 0, distance))); if (diff > maxHeightDiff) { maxHeightDiff = diff; maxHeightDiff2 = maxHeightDiff; diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/Items.java b/src/main/java/net/onelitefeather/bettergopaint/utils/Items.java index 4a5b12eb..12a9fd68 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/Items.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/Items.java @@ -20,37 +20,34 @@ import com.destroystokyo.paper.profile.ProfileProperty; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextDecoration; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.SkullMeta; import org.jetbrains.annotations.NotNull; -import java.util.ArrayList; import java.util.List; import java.util.UUID; public final class Items { + private Items() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + public static @NotNull ItemStack create( @NotNull Material material, int amount, - @NotNull String name, - @NotNull String lore + @NotNull Component name, + @NotNull List lore ) { ItemStack itemStack = new ItemStack(material); itemStack.setAmount(amount); itemStack.editMeta(itemMeta -> { + itemMeta.displayName(plain(name)); if (!lore.isEmpty()) { - String[] loreListArray = lore.split("\n"); - List loreList = new ArrayList<>(); - for (String s : loreListArray) { - loreList.add(Component.text(s)); - } - itemMeta.lore(loreList); - } - if (!name.isEmpty()) { - itemMeta.displayName(Component.text(name)); + itemMeta.lore(lore.stream().map(Items::plain).toList()); } }); return itemStack; @@ -58,11 +55,10 @@ public final class Items { public static @NotNull ItemStack createHead( @NotNull String texture, - int amount, - @NotNull String name, - @NotNull String lore + @NotNull Component name, + @NotNull List lore ) { - ItemStack head = create(Material.PLAYER_HEAD, amount, name, lore); + ItemStack head = create(Material.PLAYER_HEAD, 1, name, lore); head.editMeta(SkullMeta.class, skullMeta -> { var profile = Bukkit.createProfile(UUID.randomUUID()); profile.setProperty(new ProfileProperty("textures", texture)); @@ -71,4 +67,7 @@ public final class Items { return head; } + private static @NotNull Component plain(@NotNull Component component) { + return component.decorationIfAbsent(TextDecoration.ITALIC, TextDecoration.State.FALSE); + } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/Messages.java b/src/main/java/net/onelitefeather/bettergopaint/utils/Messages.java new file mode 100644 index 00000000..19b6b408 --- /dev/null +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/Messages.java @@ -0,0 +1,38 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.onelitefeather.bettergopaint.settings.Settings; +import org.jetbrains.annotations.NotNull; + +public final class Messages { + + private Messages() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + /** + * @return the configured chat prefix, passed as {@code } to every prefixed message + */ + public static @NotNull Component prefix(@NotNull Settings settings) { + return MiniMessage.miniMessage().deserialize(settings.generic().prefix()); + } +} diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/Sphere.java b/src/main/java/net/onelitefeather/bettergopaint/utils/Sphere.java index 12d5798b..3a347ecb 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/Sphere.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/Sphere.java @@ -18,9 +18,9 @@ */ package net.onelitefeather.bettergopaint.utils; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; import org.bukkit.Axis; -import org.bukkit.Location; -import org.bukkit.block.Block; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -36,70 +36,72 @@ private Sphere() { } /** - * Returns a stream of blocks within a specified radius from a given middle point. + * Returns a stream of positions within a specified radius from a given middle point. Positions + * outside the build limits of the extent are never returned. * + * @param extent The extent the blocks are read from. * @param middlePoint The middle point from which to calculate the radius. * @param radius The radius value. * @param axis The axis along which to calculate the radius (optional). * @param air Whether air blocks should be included. - * @return A stream of blocks within the specified radius. + * @return A stream of positions within the specified radius. */ - @Contract(value = "_, _, _, _ -> new", pure = true) - public static @NotNull Stream getBlocksInRadius(@NotNull Location middlePoint, int radius, @Nullable Axis axis, - boolean air) { - List blocks = new ArrayList<>(); - Location loc1 = middlePoint.clone().add(-radius / 2d, -radius / 2d, -radius / 2d).getBlock().getLocation(); - Location loc2 = middlePoint.clone().add(radius / 2d, radius / 2d, radius / 2d).getBlock().getLocation(); + @Contract(value = "_, _, _, _, _ -> new", pure = true) + public static @NotNull Stream getBlocksInRadius(@NotNull Extent extent, @NotNull BlockVector3 middlePoint, + int radius, @Nullable Axis axis, boolean air) { + List blocks = new ArrayList<>(); + int minX = floor(middlePoint.x() + -radius / 2d); + int minY = floor(middlePoint.y() + -radius / 2d); + int minZ = floor(middlePoint.z() + -radius / 2d); + int maxX = floor(middlePoint.x() + radius / 2d); + int maxY = floor(middlePoint.y() + radius / 2d); + int maxZ = floor(middlePoint.z() + radius / 2d); switch (axis) { case Y: - for (int x = loc1.getBlockX(); x <= loc2.getBlockX(); x++) { - for (int z = loc1.getBlockZ(); z <= loc2.getBlockZ(); z++) { - Location location = new Location(loc1.getWorld(), x, middlePoint.getBlockY(), z); - if (passesDefaultChecks(location, middlePoint, radius)) { - blocks.add(location.getBlock()); - } + for (int x = minX; x <= maxX; x++) { + for (int z = minZ; z <= maxZ; z++) { + add(blocks, BlockVector3.at(x, middlePoint.y(), z), middlePoint, radius); } } break; case X: - for (int y = loc1.getBlockY(); y <= loc2.getBlockY(); y++) { - for (int z = loc1.getBlockZ(); z <= loc2.getBlockZ(); z++) { - Location location = new Location(loc1.getWorld(), middlePoint.getBlockX(), y, z); - if (passesDefaultChecks(location, middlePoint, radius)) { - blocks.add(location.getBlock()); - } + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + add(blocks, BlockVector3.at(middlePoint.x(), y, z), middlePoint, radius); } } break; case Z: - for (int x = loc1.getBlockX(); x <= loc2.getBlockX(); x++) { - for (int y = loc1.getBlockY(); y <= loc2.getBlockY(); y++) { - Location location = new Location(loc1.getWorld(), x, y, middlePoint.getBlockZ()); - if (passesDefaultChecks(location, middlePoint, radius)) { - blocks.add(location.getBlock()); - } + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + add(blocks, BlockVector3.at(x, y, middlePoint.z()), middlePoint, radius); } } break; case null: - for (int x = loc1.getBlockX(); x <= loc2.getBlockX(); x++) { - for (int y = loc1.getBlockY(); y <= loc2.getBlockY(); y++) { - for (int z = loc1.getBlockZ(); z <= loc2.getBlockZ(); z++) { - Location location = new Location(loc1.getWorld(), x, y, z); - if (passesDefaultChecks(location, middlePoint, radius)) { - blocks.add(location.getBlock()); - } + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + add(blocks, BlockVector3.at(x, y, z), middlePoint, radius); } } } break; } - if (air) return blocks.stream(); - return blocks.stream().filter(block -> !block.isEmpty()); + return blocks.stream() + .filter(position -> Blocks.isInBounds(extent, position.y())) + .filter(position -> air || !Blocks.isAir(extent, position)); } - private static boolean passesDefaultChecks(@NotNull Location location, @NotNull Location middlePoint, int radius) { - return location.distance(middlePoint) < radius / 2d; + private static int floor(double value) { + return (int) Math.floor(value); + } + + private static void add(@NotNull List blocks, @NotNull BlockVector3 position, + @NotNull BlockVector3 middlePoint, int radius) { + if (position.distance(middlePoint) < radius / 2d) { + blocks.add(position); + } } } diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/Surface.java b/src/main/java/net/onelitefeather/bettergopaint/utils/Surface.java index 0ca21aa2..547d4b4e 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/Surface.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/Surface.java @@ -18,10 +18,10 @@ */ package net.onelitefeather.bettergopaint.utils; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; import org.jetbrains.annotations.NotNull; public final class Surface { @@ -33,50 +33,56 @@ private Surface() { /** * Checks if the given block is on the surface. * - * @param block the block to check + * @param extent the extent to read from + * @param position the position of the block to check * @return true if the block is on the surface, false otherwise */ - public static boolean isDirectlyOnSurface(@NotNull Block block) { - return block.isSolid() && !block.getRelative(BlockFace.UP).isSolid(); + public static boolean isDirectlyOnSurface(@NotNull Extent extent, @NotNull BlockVector3 position) { + return Blocks.isSolid(extent, position) && !Blocks.isSolid(extent, position.add(0, 1, 0)); } /** - * Checks if a block is on the surface, taking into account the player's location. + * Checks if a block is on the surface, taking into account the player's position. * - * @param block the block to check if the player is near - * @param playerLoc the player's location - * @return true if the block is on the surface from the player's location, false otherwise + * @param extent the extent to read from + * @param position the position of the block to check + * @param eye the position of the player's eyes + * @return true if the block is on the surface from the player's position, false otherwise */ - public static boolean isRelativelyOnSurface(@NotNull Block block, @NotNull Location playerLoc) { - Location location = block.getLocation(); + public static boolean isRelativelyOnSurface(@NotNull Extent extent, @NotNull BlockVector3 position, + @NotNull Vector3 eye) { + double distanceX = eye.x() - position.x(); + double distanceY = eye.y() - position.y(); + double distanceZ = eye.z() - position.z(); - playerLoc.add(0, 1.5, 0); - double distanceX = playerLoc.getX() - block.getX(); - double distanceY = playerLoc.getY() - block.getY(); - double distanceZ = playerLoc.getZ() - block.getZ(); + double x = position.x(); + double y = position.y(); + double z = position.z(); if (distanceX > 1) { - location.add(1, 0, 0); + x += 1; } else if (distanceX > 0) { - location.add(0.5, 0, 0); + x += 0.5; } if (distanceY > 1) { - location.add(0, 1, 0); + y += 1; } else if (distanceY > 0) { - location.add(0, 0.5, 0); + y += 0.5; } if (distanceZ > 1) { - location.add(0, 0, 1); + z += 1; } else if (distanceZ > 0) { - location.add(0, 0, 0.5); + z += 0.5; } - double distance = location.distance(playerLoc); - for (int x = 1; x < distance; x++) { - double moveX = distanceX * (x / distance); - double moveY = distanceY * (x / distance); - double moveZ = distanceZ * (x / distance); - Location checkLoc = location.add(moveX, moveY, moveZ); - if (!checkLoc.getBlock().isEmpty()) { + double dx = x - eye.x(); + double dy = y - eye.y(); + double dz = z - eye.z(); + double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + for (int step = 1; step < distance; step++) { + x += distanceX * (step / distance); + y += distanceY * (step / distance); + z += distanceZ * (step / distance); + if (!Blocks.isAir(extent, (int) Math.floor(x), (int) Math.floor(y), (int) Math.floor(z))) { return false; } } @@ -84,17 +90,19 @@ public static boolean isRelativelyOnSurface(@NotNull Block block, @NotNull Locat } /** - * Checks if a given block is on the surface based on the specified surface mode and location. + * Checks if a given block is on the surface based on the specified surface mode and eye position. * - * @param block the block to check + * @param extent the extent to read from + * @param position the position of the block to check * @param surfaceMode the surface mode to use for the check - * @param location the location to use for the check - * @return true if the block is on the surface based on the surface mode and location, false otherwise + * @param eye the position of the player's eyes + * @return true if the block is on the surface based on the surface mode and position, false otherwise */ - public static boolean isOnSurface(@NotNull Block block, @NotNull SurfaceMode surfaceMode, @NotNull Location location) { + public static boolean isOnSurface(@NotNull Extent extent, @NotNull BlockVector3 position, + @NotNull SurfaceMode surfaceMode, @NotNull Vector3 eye) { return switch (surfaceMode) { - case RELATIVE -> isRelativelyOnSurface(block, location); - case DIRECT -> isDirectlyOnSurface(block); + case RELATIVE -> isRelativelyOnSurface(extent, position, eye); + case DIRECT -> isDirectlyOnSurface(extent, position); case DISABLED -> true; }; } diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSpline.java b/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSpline.java index ef763476..6c8d2812 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSpline.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSpline.java @@ -18,34 +18,32 @@ */ package net.onelitefeather.bettergopaint.utils.curve; -import org.bukkit.Location; -import org.bukkit.World; -import org.bukkit.util.Vector; +import com.fastasyncworldedit.core.math.MutableVector3; +import com.sk89q.worldedit.math.Vector3; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.util.LinkedList; public class BezierSpline { - private final LinkedList knotsList; - private Location[] knots; + private final LinkedList knotsList; + private MutableVector3[] knots; private BezierSplineSegment[] segments; private double length = 0; - private Location anchorPoint; + private Vector3 anchorPoint; public BezierSpline() { knotsList = new LinkedList<>(); segments = new BezierSplineSegment[0]; } - public BezierSpline(@NotNull LinkedList knotsList) { + public BezierSpline(@NotNull LinkedList knotsList) { this.knotsList = knotsList; recalculate(); } private void recalculate() { - knots = knotsList.toArray(new Location[0]); + knots = knotsList.stream().map(MutableVector3::new).toArray(MutableVector3[]::new); segments = new BezierSplineSegment[knots.length - 1]; for (int i = 0; i < knots.length - 1; i++) { segments[i] = new BezierSplineSegment(knots[i], knots[i + 1]); @@ -54,8 +52,8 @@ private void recalculate() { calculateLength(); } - public void addKnot(@NotNull Location location) { - knotsList.add(location); + public void addKnot(@NotNull Vector3 knot) { + knotsList.add(knot); recalculate(); } @@ -116,7 +114,7 @@ public double getT(double blocks) { return i + segments[i].getT(blocks - current); } - public @NotNull Location getPoint(double point) { + public @NotNull Vector3 getPoint(double point) { if (point >= segments.length) { return getPoint(segments.length - 1, 1); } else { @@ -124,7 +122,7 @@ public double getT(double blocks) { } } - public @NotNull Location getPoint(int n, double f) { + public @NotNull Vector3 getPoint(int n, double f) { assert (n < segments.length); assert (0 <= f && f <= 1); BezierSplineSegment segment = segments[n]; @@ -161,45 +159,43 @@ public void calculateControlPoints() { } Double xflat, yflat, zflat; - xflat = knots[0].getX(); - yflat = knots[0].getY(); - zflat = knots[0].getZ(); - for (Location l : knots) { - if (l.getBlockX() != xflat) { + xflat = knots[0].x(); + yflat = knots[0].y(); + zflat = knots[0].z(); + for (MutableVector3 l : knots) { + if (floor(l.x()) != xflat) { xflat = null; break; } } - for (Location l : knots) { - if (l.getBlockY() != yflat) { + for (MutableVector3 l : knots) { + if (floor(l.y()) != yflat) { yflat = null; break; } } - for (Location l : knots) { - if (l.getBlockZ() != zflat) { + for (MutableVector3 l : knots) { + if (floor(l.z()) != zflat) { zflat = null; break; } } if (segments.length == 1) { - Location midpoint = new Location(segments[0].getP0().getWorld(), 0, 0, 0); - midpoint - .setX((segments[0].getP0().getX() + segments[0].getP3().getX()) / 2); - midpoint - .setY((segments[0].getP0().getY() + segments[0].getP3().getY()) / 2); - midpoint - .setZ((segments[0].getP0().getZ() + segments[0].getP3().getZ()) / 2); + MutableVector3 midpoint = new MutableVector3( + (segments[0].getP0().x() + segments[0].getP3().x()) / 2, + (segments[0].getP0().y() + segments[0].getP3().y()) / 2, + (segments[0].getP0().z() + segments[0].getP3().z()) / 2 + ); segments[0].setP1(midpoint); - segments[0].setP2(midpoint.clone()); + segments[0].setP2(new MutableVector3(midpoint)); } else { segments[0].setA(0); segments[0].setB(2); segments[0].setC(1); - segments[0].getR().setX(knots[0].getX() + 2 * knots[1].getX()); - segments[0].getR().setY(knots[0].getY() + 2 * knots[1].getY()); - segments[0].getR().setZ(knots[0].getZ() + 2 * knots[1].getZ()); + segments[0].getR().mutX(knots[0].x() + 2 * knots[1].x()); + segments[0].getR().mutY(knots[0].y() + 2 * knots[1].y()); + segments[0].getR().mutZ(knots[0].z() + 2 * knots[1].z()); int n = knots.length - 1; int i; float m; @@ -208,64 +204,64 @@ public void calculateControlPoints() { segments[i].setA(1); segments[i].setB(4); segments[i].setC(1); - segments[i].getR().setX(4 * knots[i].getX() + 2 * knots[i + 1].getX()); - segments[i].getR().setY(4 * knots[i].getY() + 2 * knots[i + 1].getY()); - segments[i].getR().setZ(4 * knots[i].getZ() + 2 * knots[i + 1].getZ()); + segments[i].getR().mutX(4 * knots[i].x() + 2 * knots[i + 1].x()); + segments[i].getR().mutY(4 * knots[i].y() + 2 * knots[i + 1].y()); + segments[i].getR().mutZ(4 * knots[i].z() + 2 * knots[i + 1].z()); } segments[n - 1].setA(2); segments[n - 1].setB(7); segments[n - 1].setC(0); - segments[n - 1].getR().setX(8 * knots[n - 1].getX() + knots[n].getX()); - segments[n - 1].getR().setY(8 * knots[n - 1].getY() + knots[n].getY()); - segments[n - 1].getR().setZ(8 * knots[n - 1].getZ() + knots[n].getZ()); + segments[n - 1].getR().mutX(8 * knots[n - 1].x() + knots[n].x()); + segments[n - 1].getR().mutY(8 * knots[n - 1].y() + knots[n].y()); + segments[n - 1].getR().mutZ(8 * knots[n - 1].z() + knots[n].z()); for (i = 1; i < n; i++) { m = segments[i].getA() / segments[i - 1].getB(); segments[i].setB(segments[i].getB() - m * segments[i - 1].getC()); - segments[i].getR().setX( - segments[i].getR().getX() - m * segments[i - 1].getR().getX()); - segments[i].getR().setY( - segments[i].getR().getY() - m * segments[i - 1].getR().getY()); - segments[i].getR().setZ( - segments[i].getR().getZ() - m * segments[i - 1].getR().getZ()); + segments[i].getR().mutX( + segments[i].getR().x() - m * segments[i - 1].getR().x()); + segments[i].getR().mutY( + segments[i].getR().y() - m * segments[i - 1].getR().y()); + segments[i].getR().mutZ( + segments[i].getR().z() - m * segments[i - 1].getR().z()); } - segments[n - 1].getP1().setX( - segments[n - 1].getR().getX() / segments[n - 1].getB()); - segments[n - 1].getP1().setY( - segments[n - 1].getR().getY() / segments[n - 1].getB()); - segments[n - 1].getP1().setZ( - segments[n - 1].getR().getZ() / segments[n - 1].getB()); + segments[n - 1].getP1().mutX( + segments[n - 1].getR().x() / segments[n - 1].getB()); + segments[n - 1].getP1().mutY( + segments[n - 1].getR().y() / segments[n - 1].getB()); + segments[n - 1].getP1().mutZ( + segments[n - 1].getR().z() / segments[n - 1].getB()); for (i = n - 2; i >= 0; i--) { - segments[i].getP1().setX( - (segments[i].getR().getX() - segments[i].getC() - * segments[i + 1].getP1().getX()) + segments[i].getP1().mutX( + (segments[i].getR().x() - segments[i].getC() + * segments[i + 1].getP1().x()) / segments[i].getB()); - segments[i].getP1().setY( - (segments[i].getR().getY() - segments[i].getC() - * segments[i + 1].getP1().getY()) + segments[i].getP1().mutY( + (segments[i].getR().y() - segments[i].getC() + * segments[i + 1].getP1().y()) / segments[i].getB()); - segments[i].getP1().setZ( - (segments[i].getR().getZ() - segments[i].getC() - * segments[i + 1].getP1().getZ()) + segments[i].getP1().mutZ( + (segments[i].getR().z() - segments[i].getC() + * segments[i + 1].getP1().z()) / segments[i].getB()); } for (i = 0; i < n - 1; i++) { - segments[i].getP2().setX( - 2 * knots[i + 1].getX() - segments[i + 1].getP1().getX()); - segments[i].getP2().setY( - 2 * knots[i + 1].getY() - segments[i + 1].getP1().getY()); - segments[i].getP2().setZ( - 2 * knots[i + 1].getZ() - segments[i + 1].getP1().getZ()); + segments[i].getP2().mutX( + 2 * knots[i + 1].x() - segments[i + 1].getP1().x()); + segments[i].getP2().mutY( + 2 * knots[i + 1].y() - segments[i + 1].getP1().y()); + segments[i].getP2().mutZ( + 2 * knots[i + 1].z() - segments[i + 1].getP1().z()); } - segments[n - 1].getP2().setX( - 0.5 * (knots[n].getX() + segments[n - 1].getP1().getX())); - segments[n - 1].getP2().setY( - 0.5 * (knots[n].getY() + segments[n - 1].getP1().getY())); - segments[n - 1].getP2().setZ( - 0.5 * (knots[n].getZ() + segments[n - 1].getP1().getZ())); + segments[n - 1].getP2().mutX( + 0.5 * (knots[n].x() + segments[n - 1].getP1().x())); + segments[n - 1].getP2().mutY( + 0.5 * (knots[n].y() + segments[n - 1].getP1().y())); + segments[n - 1].getP2().mutZ( + 0.5 * (knots[n].z() + segments[n - 1].getP1().z())); } if (xflat != null) { @@ -285,41 +281,23 @@ public void calculateControlPoints() { } } - public void shift(@NotNull Vector vector) { - for (Location location : knotsList) { - location.add(vector); - } + public void shift(@NotNull Vector3 vector) { + knotsList.replaceAll(knot -> knot.add(vector)); recalculate(); } public void scale(double d) { - for (Location l : knotsList) { - l.subtract(anchorPoint); - l.multiply(d); - l.add(anchorPoint); - } + knotsList.replaceAll(knot -> knot.subtract(anchorPoint).multiply(d).add(anchorPoint)); recalculate(); } - public void scale(@NotNull Vector vector) { - for (Location l : knotsList) { - l.subtract(anchorPoint); - l.setX(l.getX() * vector.getX()); - l.setY(l.getY() * vector.getY()); - l.setZ(l.getZ() * vector.getZ()); - l.add(anchorPoint); - } + public void scale(@NotNull Vector3 vector) { + knotsList.replaceAll(knot -> knot.subtract(anchorPoint).multiply(vector).add(anchorPoint)); recalculate(); } - public @Nullable World getWorld() { - if (knots == null) { - return null; - } - if (knots.length == 0) { - return null; - } - return knots[0].getWorld(); + private static int floor(double value) { + return (int) Math.floor(value); } @Override diff --git a/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineSegment.java b/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineSegment.java index 752f83bc..dd0e7326 100644 --- a/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineSegment.java +++ b/src/main/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineSegment.java @@ -18,27 +18,27 @@ */ package net.onelitefeather.bettergopaint.utils.curve; -import org.bukkit.Location; +import com.fastasyncworldedit.core.math.MutableVector3; +import com.sk89q.worldedit.math.Vector3; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.util.Objects; public class BezierSplineSegment { private final double[] lengths = new double[20]; - private Location p0, p1, p2, p3; + private MutableVector3 p0, p1, p2, p3; private float a, b, c; private Double xFlat, yFlat, zFlat; - private Location r; + private MutableVector3 r; private double curveLength; - public BezierSplineSegment(@NotNull Location p0, @NotNull Location p3) { + public BezierSplineSegment(@NotNull MutableVector3 p0, @NotNull MutableVector3 p3) { this.p0 = p0; this.p3 = p3; - p1 = new Location(p0.getWorld(), 0, 0, 0); - p2 = new Location(p0.getWorld(), 0, 0, 0); - r = new Location(p0.getWorld(), 0, 0, 0); + p1 = new MutableVector3(0, 0, 0); + p2 = new MutableVector3(0, 0, 0); + r = new MutableVector3(0, 0, 0); } public double getCurveLength() { @@ -46,34 +46,34 @@ public double getCurveLength() { } public void setX(double xflat2) { - p0.setX(xflat2); - p1.setX(xflat2); - p2.setX(xflat2); - p3.setX(xflat2); + p0.mutX(xflat2); + p1.mutX(xflat2); + p2.mutX(xflat2); + p3.mutX(xflat2); xFlat = xflat2; } public void setY(double yflat2) { - p0.setY(yflat2); - p1.setY(yflat2); - p2.setY(yflat2); - p3.setY(yflat2); + p0.mutY(yflat2); + p1.mutY(yflat2); + p2.mutY(yflat2); + p3.mutY(yflat2); yFlat = yflat2; } public void setZ(double zflat2) { - p0.setZ(zflat2); - p1.setZ(zflat2); - p2.setZ(zflat2); - p3.setZ(zflat2); + p0.mutZ(zflat2); + p1.mutZ(zflat2); + p2.mutZ(zflat2); + p3.mutZ(zflat2); zFlat = zflat2; } public void calculateCurveLength() { - Location current = p0.clone(); + Vector3 current = Vector3.at(p0.x(), p0.y(), p0.z()); double step = 0.05; lengths[0] = 0; - Location temp; + Vector3 temp; for (int i = 1; i < 20; i++) { temp = getPoint(i * step); lengths[i] = lengths[i - 1] + temp.distance(current); @@ -85,21 +85,21 @@ public void calculateCurveLength() { public double getdXdT(double t) { assert (t >= 0); assert (t <= 1); - return 3 * (1 - t) * (1 - t) * (p1.getX() - p0.getX()) + 6 * (1 - t) * t - * (p2.getX() - p1.getX()) + 3 * t * t * (p3.getX() - p2.getX()); + return 3 * (1 - t) * (1 - t) * (p1.x() - p0.x()) + 6 * (1 - t) * t + * (p2.x() - p1.x()) + 3 * t * t * (p3.x() - p2.x()); } public double getdYdT(double t) { assert (t <= 1); - return 3 * (1 - t) * (1 - t) * (p1.getY() - p0.getY()) + 6 * (1 - t) * t - * (p2.getY() - p1.getY()) + 3 * t * t * (p3.getY() - p2.getY()); + return 3 * (1 - t) * (1 - t) * (p1.y() - p0.y()) + 6 * (1 - t) * t + * (p2.y() - p1.y()) + 3 * t * t * (p3.y() - p2.y()); } public double getdZdT(double t) { assert (t >= 0); assert (t <= 1); - return 3 * (1 - t) * (1 - t) * (p1.getZ() - p0.getZ()) + 6 * (1 - t) * t - * (p2.getZ() - p1.getZ()) + 3 * t * t * (p3.getZ() - p2.getZ()); + return 3 * (1 - t) * (1 - t) * (p1.z() - p0.z()) + 6 * (1 - t) * t + * (p2.z() - p1.z()) + 3 * t * t * (p3.z() - p2.z()); } public double getdTdS(double t) { @@ -149,53 +149,52 @@ public double getT(double d) { return (i + (d - lengths[i - 1]) / (lengths[i] - lengths[i - 1])) / 20; } - public Location getPoint(double f) { - Location result = new Location(p0.getWorld(), 0, 0, 0); - result.setX(Objects.requireNonNullElseGet(xFlat, () -> (Math.pow(1 - f, 3) * p0.getX()) - + (3 * Math.pow(1 - f, 2) * f * p1.getX()) - + (3 * (1 - f) * f * f * p2.getX()) + (Math.pow(f, 3) * p3.getX()))); - result.setY(Objects.requireNonNullElseGet(yFlat, () -> (Math.pow(1 - f, 3) * p0.getY()) - + (3 * Math.pow(1 - f, 2) * f * p1.getY()) - + (3 * (1 - f) * f * f * p2.getY()) + (Math.pow(f, 3) * p3.getY()))); - result.setZ(Objects.requireNonNullElseGet(zFlat, () -> (Math.pow(1 - f, 3) * p0.getZ()) - + (3 * Math.pow(1 - f, 2) * f * p1.getZ()) - + (3 * (1 - f) * f * f * p2.getZ()) + (Math.pow(f, 3) * p3.getZ()))); - return result; + public @NotNull Vector3 getPoint(double f) { + double x = Objects.requireNonNullElseGet(xFlat, () -> (Math.pow(1 - f, 3) * p0.x()) + + (3 * Math.pow(1 - f, 2) * f * p1.x()) + + (3 * (1 - f) * f * f * p2.x()) + (Math.pow(f, 3) * p3.x())); + double y = Objects.requireNonNullElseGet(yFlat, () -> (Math.pow(1 - f, 3) * p0.y()) + + (3 * Math.pow(1 - f, 2) * f * p1.y()) + + (3 * (1 - f) * f * f * p2.y()) + (Math.pow(f, 3) * p3.y())); + double z = Objects.requireNonNullElseGet(zFlat, () -> (Math.pow(1 - f, 3) * p0.z()) + + (3 * Math.pow(1 - f, 2) * f * p1.z()) + + (3 * (1 - f) * f * f * p2.z()) + (Math.pow(f, 3) * p3.z())); + return Vector3.at(x, y, z); } public double getLinearLength() { return p0.distance(p3); } - public @NotNull Location getP0() { + public @NotNull MutableVector3 getP0() { return p0; } - public void setP0(@NotNull Location p0) { + public void setP0(@NotNull MutableVector3 p0) { this.p0 = p0; } - public @NotNull Location getP1() { + public @NotNull MutableVector3 getP1() { return p1; } - public void setP1(@NotNull Location p1) { + public void setP1(@NotNull MutableVector3 p1) { this.p1 = p1; } - public @NotNull Location getP2() { + public @NotNull MutableVector3 getP2() { return p2; } - public void setP2(@NotNull Location p2) { + public void setP2(@NotNull MutableVector3 p2) { this.p2 = p2; } - public @NotNull Location getP3() { + public @NotNull MutableVector3 getP3() { return p3; } - public void setP3(@NotNull Location p3) { + public void setP3(@NotNull MutableVector3 p3) { this.p3 = p3; } @@ -223,11 +222,11 @@ public void setC(float c) { this.c = c; } - public @NotNull Location getR() { + public @NotNull MutableVector3 getR() { return r; } - public void setR(@NotNull Location r) { + public void setR(@NotNull MutableVector3 r) { this.r = r; } diff --git a/src/main/resources/bettergopaint.properties b/src/main/resources/bettergopaint.properties index f5f3be7e..ddc09ceb 100644 --- a/src/main/resources/bettergopaint.properties +++ b/src/main/resources/bettergopaint.properties @@ -3,3 +3,64 @@ bettergopaint.notify.donation.console=To keep the project alive, we ask f bettergopaint.notify.donation.player= To keep the project alive, we ask for donations to our OpenCollective bettergopaint.notify.update.player= Your version() is older than our latest published version (). Please update as soon as possible to get continued support. Or click me to get on the download page! bettergopaint.notify.update.console=Your version() is older than our latest published version (). Please update as soon as possible to get continued support. Or click use this link +bettergopaint.command.size.set= Size set to: +bettergopaint.command.size.out_of_range= The size has to be between and . +bettergopaint.command.toggle.enabled= Enabled brush +bettergopaint.command.toggle.disabled= Disabled brush +bettergopaint.command.info= BetterGoPaint by OneLiteFeather, based on goPaint by Arcaniax. GitHub +bettergopaint.command.reload.done= Reloaded config.yml +bettergopaint.brush.disabled= Your brush is disabled, left click to enable the brush. +bettergopaint.menu.title=goPaint Menu +bettergopaint.menu.picker.title=goPaint Brushes +bettergopaint.menu.picker.select=Click to select +bettergopaint.menu.brush.name=goPaint Brush +bettergopaint.menu.brush.enabled=Enabled +bettergopaint.menu.brush.disabled=Disabled +bettergopaint.menu.brush.export=Left click with item to export +bettergopaint.menu.brush.toggle=Right click to toggle +bettergopaint.menu.type.name=Selected Brush type +bettergopaint.menu.type.select=Shift click to select +bettergopaint.menu.type.cycle=Click to cycle brush +bettergopaint.menu.type.entry= +bettergopaint.menu.type.entry.selected= +bettergopaint.menu.click.increase=Left click to increase +bettergopaint.menu.click.decrease=Right click to decrease +bettergopaint.menu.click.change=Click to change +bettergopaint.menu.click.toggle=Click to toggle +bettergopaint.menu.click.shift_10=Shift click to change by 10 +bettergopaint.menu.click.shift_15=Shift click to change by 15 +bettergopaint.menu.chance=Place chance: % +bettergopaint.menu.axis=Axis: +bettergopaint.menu.thickness=Layer Thickness: +bettergopaint.menu.angle_distance=Angle Check Distance: +bettergopaint.menu.angle_height=Maximum Angle: ° +bettergopaint.menu.fracture_distance=Fracture Check Distance: +bettergopaint.menu.mixing=Mixing Strength: % +bettergopaint.menu.falloff=Falloff Strength: % +bettergopaint.menu.size=Brush Size: +bettergopaint.menu.mask=Mask +bettergopaint.menu.mask.current=Current Mask +bettergopaint.menu.mask.change=Left click with a block to change +bettergopaint.menu.surface=Surface Mode +bettergopaint.menu.surface.direct=Direct +bettergopaint.menu.surface.disabled=Disabled +bettergopaint.menu.surface.relative=Relative +bettergopaint.menu.slot=Slot % +bettergopaint.menu.slot.empty=Empty Slot +bettergopaint.menu.slot.empty.hint=Click with a block to set +bettergopaint.menu.slot.change=Left click with a block to change +bettergopaint.menu.slot.clear=Right click to clear +bettergopaint.export.name= ♦ ♦ +bettergopaint.export.size=Size: +bettergopaint.export.chance=Chance: % +bettergopaint.export.thickness=Thickness: +bettergopaint.export.axis=Axis: +bettergopaint.export.angle_distance=Angle distance: +bettergopaint.export.angle_height=Angle height difference: +bettergopaint.export.falloff=Falloff: +bettergopaint.export.mixing=Mixing: +bettergopaint.export.fracture_distance=Fracture distance: +bettergopaint.export.blocks=Blocks: +bettergopaint.export.mask=Mask: +bettergopaint.export.surface=Surface mode: +bettergopaint.brush.paint.point= Paint brush point # set. Sneak and click to paint the stroke. diff --git a/src/test/java/net/onelitefeather/bettergopaint/brush/ExportedBrushCodecTest.java b/src/test/java/net/onelitefeather/bettergopaint/brush/ExportedBrushCodecTest.java new file mode 100644 index 00000000..658609d9 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/brush/ExportedBrushCodecTest.java @@ -0,0 +1,112 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import net.onelitefeather.bettergopaint.objects.brush.Brush; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import org.bukkit.Axis; +import org.bukkit.Material; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExportedBrushCodecTest { + + private final Settings settings = Settings.defaults(); + private final PlayerBrushManager manager = new PlayerBrushManager(SettingsHolder.of(settings)); + private final Brush disc = manager.getBrushByName("Disc Brush").orElseThrow(); + + @Test + @DisplayName("every setting survives encoding and decoding") + void roundTrip() { + ExportedPlayerBrush original = ExportedPlayerBrush.builder(disc, settings) + .size(17) + .chance(30) + .thickness(3) + .axis(Axis.X) + .angleDistance(4) + .angleHeightDifference(55.5) + .fractureDistance(3) + .falloffStrength(70) + .mixingStrength(20) + .blocks(List.of(Material.STONE, Material.ANDESITE)) + .mask(Material.GRASS_BLOCK) + .surfaceMode(SurfaceMode.RELATIVE) + .build(); + + String encoded = ExportedBrushCodec.encode(original); + + assertThat(encoded).startsWith("version=" + ExportedBrushCodec.FORMAT_VERSION + ";"); + assertThat(ExportedBrushCodec.decode(encoded, manager::getBrushByName, settings)).contains(original); + } + + @Test + @DisplayName("unreadable values fall back to the configured defaults") + void invalidValuesUseDefaults() { + var decoded = ExportedBrushCodec.decode("version=1;brush=Disc Brush;size=ten;axis=W;blocks=minecraft:stone,nope", + manager::getBrushByName, settings); + + assertThat(decoded).hasValueSatisfying(brush -> { + assertThat(brush.size()).isEqualTo(settings.generic().defaultSize()); + assertThat(brush.axis()).isEqualTo(settings.generic().defaultAxis()); + assertThat(brush.blocks()).containsExactly(Material.STONE); + assertThat(brush.maskEnabled()).isFalse(); + }); + } + + @Test + @DisplayName("an unknown or missing brush yields no brush") + void unknownBrushIsEmpty() { + assertThat(ExportedBrushCodec.decode("version=1;brush=Teleport Brush;size=3", manager::getBrushByName, settings)).isEmpty(); + assertThat(ExportedBrushCodec.decode("garbage", manager::getBrushByName, settings)).isEmpty(); + } + + @Test + @DisplayName("lore written by 1.x is read line by line") + void legacyLoreIsRead() { + ExportedPlayerBrush brush = ExportedBrushCodec.decodeLegacy(disc, List.of( + "", + "Size: 12", + "Axis: z", + "Blocks: minecraft:stone, minecraft:dirt", + "Mask: minecraft:sponge", + "Surface Mode: Relative" + ), settings); + + assertThat(brush.size()).isEqualTo(12); + assertThat(brush.axis()).isEqualTo(Axis.Z); + assertThat(brush.blocks()).containsExactly(Material.STONE, Material.DIRT); + assertThat(brush.mask()).isEqualTo(Material.SPONGE); + assertThat(brush.surfaceMode()).isEqualTo(SurfaceMode.RELATIVE); + } + + @Test + @DisplayName("edited legacy lore never throws and keeps the default") + void editedLegacyLoreUsesDefaults() { + ExportedPlayerBrush brush = ExportedBrushCodec.decodeLegacy(disc, List.of("Size: ten", "Chance: %", "Axis:"), settings); + + assertThat(brush.size()).isEqualTo(settings.generic().defaultSize()); + assertThat(brush.chance()).isEqualTo(settings.generic().defaultChance()); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java b/src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java new file mode 100644 index 00000000..afef5d45 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/brush/PlayerBrushManagerTest.java @@ -0,0 +1,146 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import net.onelitefeather.bettergopaint.objects.brush.Brush; +import net.onelitefeather.bettergopaint.objects.brush.PaintBrush; +import net.onelitefeather.bettergopaint.objects.brush.SphereBrush; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PlayerBrushManagerTest { + + private PlayerBrushManager manager; + private List brushes; + + @BeforeEach + void setUp() { + manager = new PlayerBrushManager(SettingsHolder.of(Settings.defaults())); + brushes = manager.getBrushes(); + } + + @Test + @DisplayName("a player without a brush starts on the first one") + void cycleForwardWithoutBrushStartsAtFirst() { + assertThat(manager.cycleForward(null)).isInstanceOf(SphereBrush.class); + assertThat(manager.cycleBack(null)).isInstanceOf(SphereBrush.class); + } + + @Test + @DisplayName("cycling forward walks the list and wraps to the first brush") + void cycleForwardWraps() { + assertThat(manager.cycleForward(brushes.getFirst())).isSameAs(brushes.get(1)); + assertThat(manager.cycleForward(brushes.getLast())).isSameAs(brushes.getFirst()); + } + + @Test + @DisplayName("cycling backwards walks the list and wraps to the last brush") + void cycleBackWraps() { + assertThat(manager.cycleBack(brushes.get(1))).isSameAs(brushes.getFirst()); + assertThat(manager.cycleBack(brushes.getFirst())).isSameAs(brushes.getLast()); + } + + @Test + @DisplayName("every brush can be found again by its name") + void everyBrushIsFoundByName() { + for (Brush brush : brushes) { + assertThat(manager.getBrushHandler(brush.getName())) + .as("lookup of %s", brush.getName()) + .containsSame(brush); + } + assertThat(manager.getBrushHandler("Paint Brush")).containsInstanceOf(PaintBrush.class); + } + + @Test + @DisplayName("an unknown name finds no brush") + void unknownNameFindsNothing() { + assertThat(manager.getBrushHandler("Teleport Brush")).isEmpty(); + } + + @Test + @DisplayName("an exact name finds exactly that brush") + void exactNameLookup() { + for (Brush brush : brushes) { + assertThat(manager.getBrushByName(brush.getName())).containsSame(brush); + } + assertThat(manager.getBrushByName(" ♦ Sphere Brush ♦ ")).isEmpty(); + } + + @Test + @DisplayName("concurrent lookups for one player share a single brush") + void concurrentLookupsShareOneBrush() throws Exception { + AtomicInteger created = new AtomicInteger(); + PlayerBrushManager concurrent = new PlayerBrushManager( + SettingsHolder.of(Settings.defaults()), + (ignored, locale) -> { + created.incrementAndGet(); + return mock(PlayerBrush.class); + } + ); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.locale()).thenReturn(Locale.ENGLISH); + + int threads = 16; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + results.add(pool.submit((Callable) () -> { + start.await(); + return concurrent.getBrush(player); + })); + } + start.countDown(); + + Set distinct = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Future result : results) { + distinct.add(result.get(10, TimeUnit.SECONDS)); + } + assertThat(distinct).hasSize(1); + assertThat(created).hasValue(1); + } finally { + pool.shutdownNow(); + } + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactoryTest.java b/src/test/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactoryTest.java new file mode 100644 index 00000000..131b2c4f --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/brush/StrokeContextFactoryTest.java @@ -0,0 +1,187 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.brush; + +import com.sk89q.worldedit.function.pattern.RandomPattern; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import net.onelitefeather.bettergopaint.support.TestBlocks; +import org.bukkit.Axis; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class StrokeContextFactoryTest { + + private final World world = mock(World.class); + private final BrushSettings settings = mock(BrushSettings.class); + private final Map converted = new HashMap<>(); + private final Function converter = + material -> converted.computeIfAbsent(material, key -> TestBlocks.solid(key.name())); + + private void stubSettings(boolean maskEnabled, Material... blocks) { + when(settings.blocks()).thenReturn(List.of(blocks)); + when(settings.maskEnabled()).thenReturn(maskEnabled); + when(settings.mask()).thenReturn(Material.GRASS_BLOCK); + when(settings.axis()).thenReturn(Axis.X); + when(settings.surfaceMode()).thenReturn(SurfaceMode.RELATIVE); + when(settings.size()).thenReturn(7); + when(settings.chance()).thenReturn(40); + when(settings.falloffStrength()).thenReturn(30); + when(settings.mixingStrength()).thenReturn(20); + when(settings.thickness()).thenReturn(3); + when(settings.fractureDistance()).thenReturn(4); + when(settings.angleDistance()).thenReturn(5); + when(settings.angleHeightDifference()).thenReturn(33.5); + } + + private StrokeContext capture(Random random) { + return StrokeContextFactory.capture( + new Location(world, 10, 64, -20), new Location(world, 1.5, 70, 2.5), settings, converter, random + ); + } + + @Test + @DisplayName("capturing a context never asks the world for anything") + void needsNoWorldAccess() { + stubSettings(true, Material.STONE, Material.ANDESITE); + + capture(new Random(1)); + + verifyNoInteractions(world); + } + + @Test + @DisplayName("the context holds the settings, the target and the eye position") + void snapshotsSettings() { + stubSettings(false, Material.STONE); + + StrokeContext context = capture(new Random(1)); + + assertThat(context.target()).isEqualTo(BlockVector3.at(10, 64, -20)); + assertThat(context.points()).containsExactly(BlockVector3.at(10, 64, -20)); + assertThat(context.eye().x()).isCloseTo(1.5, within(1e-9)); + assertThat(context.eye().y()).isCloseTo(71.5, within(1e-9)); + assertThat(context.eye().z()).isCloseTo(2.5, within(1e-9)); + assertThat(context.axis()).isEqualTo(Axis.X); + assertThat(context.surfaceMode()).isEqualTo(SurfaceMode.RELATIVE); + assertThat(context.size()).isEqualTo(7); + assertThat(context.chance()).isEqualTo(40); + assertThat(context.falloffStrength()).isEqualTo(30); + assertThat(context.mixingStrength()).isEqualTo(20); + assertThat(context.thickness()).isEqualTo(3); + assertThat(context.fractureDistance()).isEqualTo(4); + assertThat(context.angleDistance()).isEqualTo(5); + assertThat(context.angleHeightDifference()).isEqualTo(33.5); + } + + @Test + @DisplayName("materials are converted once and keep their order") + void convertsMaterials() { + stubSettings(false, Material.STONE, Material.ANDESITE, Material.STONE); + + StrokeContext context = capture(new Random(1)); + + assertThat(context.blocks()).containsExactly( + converted.get(Material.STONE), converted.get(Material.ANDESITE), converted.get(Material.STONE) + ); + assertThat(converted).hasSize(2); + } + + @Test + @DisplayName("a disabled mask is null") + void disabledMask() { + stubSettings(false, Material.STONE); + + assertThat(capture(new Random(1)).mask()).isNull(); + } + + @Test + @DisplayName("an enabled mask accepts the mask material") + void enabledMask() { + stubSettings(true, Material.STONE); + + StrokeContext context = capture(new Random(1)); + + assertThat(context.mask()).isInstanceOfSatisfying(SingleTypeMask.class, + mask -> assertThat(mask.type()).isSameAs(converted.get(Material.GRASS_BLOCK))); + } + + @Test + @DisplayName("the palette picks every listed block with the same weight") + void paletteWeightsEntriesEqually() { + BlockType stone = TestBlocks.solid("stone"); + BlockType andesite = TestBlocks.solid("andesite"); + RandomPattern palette = StrokeContextFactory.palette(List.of(stone, andesite), new Random(7)); + + Map counts = new HashMap<>(); + for (int i = 0; i < 4000; i++) { + counts.merge(palette.applyBlock(BlockVector3.at(i, 0, 0)).getBlockType(), 1, Integer::sum); + } + + assertThat(counts).containsOnlyKeys(stone, andesite); + assertThat(counts.get(stone)).isBetween(1800, 2200); + assertThat(counts.get(andesite)).isBetween(1800, 2200); + } + + @Test + @DisplayName("a block that is listed twice is picked twice as often") + void paletteKeepsDuplicates() { + BlockType stone = TestBlocks.solid("stone"); + BlockType andesite = TestBlocks.solid("andesite"); + RandomPattern palette = StrokeContextFactory.palette(List.of(stone, stone, andesite), new Random(7)); + + Map counts = new HashMap<>(); + for (int i = 0; i < 6000; i++) { + counts.merge(palette.applyBlock(BlockVector3.at(i, 0, 0)).getBlockType(), 1, Integer::sum); + } + + assertThat(counts.get(stone)).isBetween(3700, 4300); + assertThat(counts.get(andesite)).isBetween(1700, 2300); + } + + @Test + @DisplayName("the same seed gives the same palette picks") + void paletteIsSeeded() { + BlockType stone = TestBlocks.solid("stone"); + BlockType andesite = TestBlocks.solid("andesite"); + RandomPattern first = StrokeContextFactory.palette(List.of(stone, andesite), new Random(99)); + RandomPattern second = StrokeContextFactory.palette(List.of(stone, andesite), new Random(99)); + + for (int i = 0; i < 50; i++) { + BlockVector3 position = BlockVector3.at(i, 0, 0); + assertThat(first.applyBlock(position).getBlockType()).isSameAs(second.applyBlock(position).getBlockType()); + } + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/command/BrushSizesTest.java b/src/test/java/net/onelitefeather/bettergopaint/command/BrushSizesTest.java new file mode 100644 index 00000000..206f9402 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/command/BrushSizesTest.java @@ -0,0 +1,52 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.command; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BrushSizesTest { + + @Test + @DisplayName("sizes from 1 up to the configured maximum are valid") + void boundsComeFromTheMaximum() { + assertThat(BrushSizes.isValid(1, 100)).isTrue(); + assertThat(BrushSizes.isValid(100, 100)).isTrue(); + assertThat(BrushSizes.isValid(0, 100)).isFalse(); + assertThat(BrushSizes.isValid(101, 100)).isFalse(); + assertThat(BrushSizes.isValid(101, 200)).isTrue(); + } + + @Test + @DisplayName("suggestions stay inside the range and include the maximum") + void suggestionsStayInRange() { + assertThat(BrushSizes.suggestions("", 23)) + .contains("1", "10", "15", "20", "23") + .doesNotContain("0", "25") + .allSatisfy(size -> assertThat(Integer.parseInt(size)).isBetween(1, 23)); + } + + @Test + @DisplayName("suggestions follow what the player typed") + void suggestionsFilterByInput() { + assertThat(BrushSizes.suggestions("2", 30)).containsExactly("2", "20", "25"); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/docs/ReferenceDocsTest.java b/src/test/java/net/onelitefeather/bettergopaint/docs/ReferenceDocsTest.java new file mode 100644 index 00000000..25b91502 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/docs/ReferenceDocsTest.java @@ -0,0 +1,128 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.docs; + +import net.onelitefeather.bettergopaint.settings.SettingsLoader; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Keeps the hand-written reference pages in step with the sources the plugin itself reads, so a + * renamed key, a new permission or a changed default fails the build instead of the docs drifting. + */ +class ReferenceDocsTest { + + private static final Path DOCS = Path.of("docs"); + private static final Path BUILD_SCRIPT = Path.of("build.gradle.kts"); + private static final Path COMMANDS = Path.of("src", "main", "java", "net", "onelitefeather", "bettergopaint", + "command", "GoPaintCommand.java"); + + @TempDir + Path folder; + + private static String read(Path path) throws IOException { + return Files.readString(path); + } + + private static List matches(Pattern pattern, String text) { + List found = new ArrayList<>(); + Matcher matcher = pattern.matcher(text); + while (matcher.find()) { + found.add(matcher.group(1)); + } + return found; + } + + @Test + @DisplayName("every config key and its default is documented") + void configurationIsDocumented() throws IOException { + Path file = folder.resolve("config.yml"); + SettingsLoader.load(file); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file.toFile()); + String page = read(DOCS.resolve("reference/configuration.md")); + + for (String path : yaml.getKeys(true)) { + if (yaml.isConfigurationSection(path)) { + assertThat(page).as("section %s", path).contains("## " + path); + continue; + } + String key = path.substring(path.indexOf('.') + 1); + Object value = yaml.get(path); + String shown = value instanceof List list && list.isEmpty() ? "[]" : String.valueOf(value); + assertThat(page).as("row for %s with default %s", path, shown) + .containsPattern("\\| `" + Pattern.quote(key) + "` \\| `" + Pattern.quote(shown) + "` \\|"); + } + } + + @Test + @DisplayName("every permission declared in the build is documented") + void permissionsAreDocumented() throws IOException { + List permissions = matches(Pattern.compile("register\\(\"(bettergopaint\\.[^\"]+)\"\\)"), read(BUILD_SCRIPT)); + String page = read(DOCS.resolve("reference/permissions.md")); + + assertThat(permissions).isNotEmpty(); + permissions.forEach(permission -> assertThat(page).as(permission).contains("| `" + permission + "` |")); + } + + @Test + @DisplayName("every subcommand is documented") + void commandsAreDocumented() throws IOException { + List subcommands = matches(Pattern.compile("@Command\\(\"gopaint\\|gp\\|bgp ([a-z]+)"), read(COMMANDS)); + String page = read(DOCS.resolve("reference/commands.md")); + + assertThat(subcommands).isNotEmpty(); + subcommands.forEach(subcommand -> assertThat(page).as(subcommand).contains("/gopaint " + subcommand)); + } + + @Test + @DisplayName("every supported Minecraft version is documented") + void supportedVersionsAreDocumented() throws IOException { + String script = read(BUILD_SCRIPT); + String list = script.substring(script.indexOf("val supportedMinecraftVersions"), script.indexOf(")", script.indexOf("val supportedMinecraftVersions"))); + List versions = matches(Pattern.compile("\"([0-9.]+)\""), list); + String page = read(DOCS.resolve("reference/supported-versions.md")); + + assertThat(versions).isNotEmpty(); + versions.forEach(version -> assertThat(page).as(version).contains(version)); + } + + @Test + @DisplayName("every user page is listed in the navigation") + void everyPageIsInTheNavigation() throws IOException { + String summary = read(DOCS.resolve("SUMMARY.md")); + try (var pages = Files.walk(DOCS)) { + pages.filter(path -> path.toString().endsWith(".md")) + .map(path -> DOCS.relativize(path).toString().replace('\\', '/')) + .filter(page -> !page.equals("SUMMARY.md") && !page.startsWith("development/")) + .forEach(page -> assertThat(summary).as(page).contains("(" + page + ")")); + } + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/injection/DIValidationTest.java b/src/test/java/net/onelitefeather/bettergopaint/injection/DIValidationTest.java new file mode 100644 index 00000000..7055bbfe --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/injection/DIValidationTest.java @@ -0,0 +1,120 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.injection; + +import com.google.inject.AbstractModule; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.Stage; +import com.google.inject.TypeLiteral; +import com.google.inject.util.Modules; +import io.papermc.paper.plugin.configuration.PluginMeta; +import net.kyori.adventure.text.logger.slf4j.ComponentLogger; +import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; +import net.onelitefeather.bettergopaint.command.GoPaintCommand; +import net.onelitefeather.bettergopaint.listeners.ConnectListener; +import net.onelitefeather.bettergopaint.listeners.InteractListener; +import net.onelitefeather.bettergopaint.listeners.InventoryListener; +import net.onelitefeather.bettergopaint.service.UpdateService; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import org.bukkit.event.Listener; +import org.bukkit.plugin.java.JavaPlugin; +import org.incendo.cloud.minecraft.extras.MinecraftHelp; +import org.incendo.cloud.paper.util.sender.Source; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Builds the real module graph with a mocked plugin, so a missing or broken binding fails here + * instead of on server start. Only the command manager, which needs a running server, is replaced. + */ +class DIValidationTest { + + @TempDir + Path dataFolder; + + private Injector injector; + + @BeforeEach + void setUp() { + JavaPlugin plugin = mock(JavaPlugin.class); + PluginMeta meta = mock(PluginMeta.class); + when(meta.getVersion()).thenReturn("2.0.0"); + when(plugin.getPluginMeta()).thenReturn(meta); + when(plugin.getComponentLogger()).thenReturn(mock(ComponentLogger.class)); + + @SuppressWarnings("unchecked") + MinecraftHelp help = mock(MinecraftHelp.class); + injector = Guice.createInjector(Stage.DEVELOPMENT, + new PlatformModule(plugin), + new SettingsModule(dataFolder), + new BrushModule(), + new ServiceModule(), + new ListenerModule(), + Modules.override(new CommandModule()).with(new AbstractModule() { + @Override + protected void configure() { + bind(new TypeLiteral>() { + }).toInstance(help); + } + }) + ); + } + + @Test + @DisplayName("every listener can be created and is registered") + void listenersAreBound() { + Set listeners = injector.getInstance(Key.get(new TypeLiteral>() { + })); + + assertThat(listeners).hasExactlyElementsOfTypes(ConnectListener.class, InteractListener.class, InventoryListener.class); + } + + @Test + @DisplayName("the command handler can be created") + void commandIsBound() { + assertThat(injector.getInstance(GoPaintCommand.class)).isNotNull(); + } + + @Test + @DisplayName("shared state is a singleton") + void singletons() { + assertThat(injector.getInstance(SettingsHolder.class)).isSameAs(injector.getInstance(SettingsHolder.class)); + assertThat(injector.getInstance(PlayerBrushManager.class)).isSameAs(injector.getInstance(PlayerBrushManager.class)); + assertThat(injector.getInstance(UpdateService.class)).isSameAs(injector.getInstance(UpdateService.class)); + } + + @Test + @DisplayName("the config file is created in the data folder") + void configIsWritten() { + injector.getInstance(SettingsHolder.class); + + assertThat(dataFolder.resolve("config.yml")).exists(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/listeners/ConnectListenerTest.java b/src/test/java/net/onelitefeather/bettergopaint/listeners/ConnectListenerTest.java new file mode 100644 index 00000000..6f671358 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/listeners/ConnectListenerTest.java @@ -0,0 +1,71 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.listeners; + +import net.kyori.adventure.text.Component; +import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; +import net.onelitefeather.bettergopaint.service.UpdateService; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import net.onelitefeather.bettergopaint.utils.Constants; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ConnectListenerTest { + + private final UpdateService updateService = mock(UpdateService.class); + private final Player player = mock(Player.class); + private ConnectListener listener; + + @BeforeEach + void setUp() { + listener = new ConnectListener(mock(PlayerBrushManager.class), updateService, SettingsHolder.of(Settings.defaults())); + } + + private PlayerJoinEvent join() { + return new PlayerJoinEvent(player, Component.empty()); + } + + @Test + @DisplayName("players with the update permission are told about updates when they join") + void adminIsNotifiedOnJoin() { + when(player.hasPermission(Constants.PERMISSION_NOTIFY_UPDATE)).thenReturn(true); + + listener.onJoin(join()); + + verify(updateService).notifyPlayer(player); + } + + @Test + @DisplayName("players without the update permission are not told about updates") + void playerIsNotNotified() { + listener.onJoin(join()); + + verify(updateService, never()).notifyPlayer(any()); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/listeners/InteractListenerTest.java b/src/test/java/net/onelitefeather/bettergopaint/listeners/InteractListenerTest.java new file mode 100644 index 00000000..852f3a0a --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/listeners/InteractListenerTest.java @@ -0,0 +1,116 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.listeners; + +import net.onelitefeather.bettergopaint.brush.PlayerBrush; +import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import net.onelitefeather.bettergopaint.utils.Constants; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class InteractListenerTest { + + private final PlayerBrushManager brushManager = mock(PlayerBrushManager.class); + private final Player player = mock(Player.class); + private final ItemStack feather = mock(ItemStack.class); + private final World world = mock(World.class); + private final Block clicked = mock(Block.class); + private InteractListener listener; + + @BeforeEach + void setUp() { + Settings defaults = Settings.defaults(); + Settings settings = new Settings( + new Settings.Generic(List.of(), Material.FEATHER, 100, 10, 50, defaults.generic().defaultAxis(), 50, 50, + "", false, List.of("world_nether"), true, true, Material.SPONGE, defaults.generic().surfaceMode()), + defaults.thickness(), defaults.angle(), defaults.fracture()); + listener = new InteractListener(brushManager, SettingsHolder.of(settings)); + when(feather.getType()).thenReturn(Material.FEATHER); + when(clicked.getLocation()).thenAnswer(invocation -> new Location(world, 0, 64, 0)); + PlayerBrush brush = mock(PlayerBrush.class); + when(brushManager.getBrush(player)).thenReturn(brush); + } + + private PlayerInteractEvent rightClick() { + return new PlayerInteractEvent(player, Action.RIGHT_CLICK_BLOCK, feather, clicked, BlockFace.UP); + } + + @Test + @DisplayName("players without bettergopaint.use cannot paint") + void permissionIsRequired() { + listener.onClick(rightClick()); + + verify(brushManager, never()).getBrush(any()); + verify(feather, never()).hasItemMeta(); + } + + @Test + @DisplayName("painting in a disabled world is ignored") + void disabledWorldIsIgnored() { + when(player.hasPermission(Constants.USE_PERMISSION)).thenReturn(true); + when(world.getName()).thenReturn("world_nether"); + + listener.onClick(rightClick()); + + verify(brushManager, never()).getBrush(any()); + } + + @Test + @DisplayName("the bypass permission allows painting in a disabled world") + void bypassAllowsDisabledWorld() { + when(player.hasPermission(Constants.USE_PERMISSION)).thenReturn(true); + when(player.hasPermission(Constants.WORLD_BYPASS_PERMISSION)).thenReturn(true); + when(world.getName()).thenReturn("world_nether"); + + listener.onClick(rightClick()); + + verify(brushManager).getBrush(player); + } + + @Test + @DisplayName("painting in a normal world uses the player's brush") + void normalWorldUsesPlayerBrush() { + when(player.hasPermission(Constants.USE_PERMISSION)).thenReturn(true); + when(world.getName()).thenReturn("world"); + + listener.onClick(rightClick()); + + verify(brushManager).getBrush(player); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/listeners/InventoryListenerTest.java b/src/test/java/net/onelitefeather/bettergopaint/listeners/InventoryListenerTest.java new file mode 100644 index 00000000..0235ce10 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/listeners/InventoryListenerTest.java @@ -0,0 +1,75 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.listeners; + +import net.kyori.adventure.text.Component; +import net.onelitefeather.bettergopaint.brush.PlayerBrushManager; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class InventoryListenerTest { + + private final PlayerBrushManager brushManager = mock(PlayerBrushManager.class); + private final InventoryListener listener = new InventoryListener(brushManager, SettingsHolder.of(Settings.defaults())); + + private InventoryClickEvent clickInForeignMenuTitled(String title) { + Inventory top = mock(Inventory.class); + when(top.getHolder(false)).thenReturn(mock(InventoryHolder.class)); + InventoryView view = mock(InventoryView.class); + when(view.getTopInventory()).thenReturn(top); + when(view.getInventory(10)).thenReturn(top); + when(view.getPlayer()).thenReturn(mock(Player.class)); + when(view.title()).thenReturn(Component.text(title)); + return new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, 10, ClickType.LEFT, InventoryAction.PICKUP_ALL); + } + + @Test + @DisplayName("a foreign inventory titled like the brush menu is left alone") + void foreignMenuIsIgnored() { + InventoryClickEvent event = clickInForeignMenuTitled("goPaint Menu"); + + listener.menuClick(event); + + assertThat(event.isCancelled()).isFalse(); + } + + @Test + @DisplayName("a foreign inventory titled like the brush picker is left alone") + void foreignPickerIsIgnored() { + InventoryClickEvent event = clickInForeignMenuTitled("goPaint Brushes"); + + listener.menuBrushClick(event); + + assertThat(event.isCancelled()).isFalse(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/objects/brush/NoBukkitWorldAccessTest.java b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/NoBukkitWorldAccessTest.java new file mode 100644 index 00000000..37339c60 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/NoBukkitWorldAccessTest.java @@ -0,0 +1,74 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.objects.brush; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Guards the rule that a stroke is computed from WorldEdit types only. The brushes and the geometry + * helpers must not call Bukkit world, block or location methods, because FAWE runs them on a worker thread. + */ +class NoBukkitWorldAccessTest { + + private static final List ROOTS = List.of( + Path.of("src/main/java/net/onelitefeather/bettergopaint/objects"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/Sphere.java"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/Height.java"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/Surface.java"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocks.java"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/Blocks.java"), + Path.of("src/main/java/net/onelitefeather/bettergopaint/utils/curve") + ); + + private static final Pattern FORBIDDEN = Pattern.compile( + "getBlock\\(\\)|getBlockAt|org\\.bukkit\\.block\\.|org\\.bukkit\\.Location|org\\.bukkit\\.World|" + + "org\\.bukkit\\.entity\\.|org\\.bukkit\\.Bukkit|\\.getWorld\\(\\)" + ); + + @Test + @DisplayName("brush and geometry code does not use Bukkit worlds, blocks, locations or players") + void brushCodeIsFreeOfBukkitWorldAccess() throws IOException { + List offences = new java.util.ArrayList<>(); + for (Path root : ROOTS) { + assertThat(root).exists(); + try (Stream files = Files.walk(root)) { + for (Path file : files.filter(path -> path.toString().endsWith(".java")).toList()) { + List lines = Files.readAllLines(file); + for (int i = 0; i < lines.size(); i++) { + if (FORBIDDEN.matcher(lines.get(i)).find()) { + offences.add(file + ":" + (i + 1) + " " + lines.get(i).strip()); + } + } + } + } + } + + assertThat(offences).isEmpty(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrushTest.java b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrushTest.java new file mode 100644 index 00000000..187f41ce --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/PaintBrushTest.java @@ -0,0 +1,68 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.objects.brush; + +import com.sk89q.worldedit.math.BlockVector3; +import net.kyori.adventure.audience.Audience; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static net.onelitefeather.bettergopaint.support.StrokeBuilder.stroke; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class PaintBrushTest { + + private final PaintBrush brush = new PaintBrush(SettingsHolder.of(Settings.defaults())); + private final Audience feedback = mock(Audience.class); + private final UUID player = UUID.randomUUID(); + private final UUID other = UUID.randomUUID(); + + @Test + @DisplayName("the points of a stroke are collected until the player sneaks to end it") + void pointsAccumulateUntilSneaking() { + assertThat(brush.prepare(player, false, feedback, stroke().target(BlockVector3.at(1, 64, 1)).build())).isNull(); + assertThat(brush.prepare(player, false, feedback, stroke().target(BlockVector3.at(2, 64, 2)).build())).isNull(); + + StrokeContext finished = brush.prepare(player, true, feedback, stroke().target(BlockVector3.at(3, 64, 3)).build()); + + assertThat(finished).isNotNull(); + assertThat(finished.points()).hasSize(3); + } + + @Test + @DisplayName("forgetting a player drops only that player's unfinished stroke") + void forgetDropsOnlyThatPlayer() { + brush.prepare(player, false, feedback, stroke().target(BlockVector3.at(1, 64, 1)).build()); + brush.prepare(other, false, feedback, stroke().target(BlockVector3.at(5, 64, 5)).build()); + + brush.forget(player); + + StrokeContext ownStroke = brush.prepare(player, true, feedback, stroke().target(BlockVector3.at(9, 64, 9)).build()); + assertThat(ownStroke.points()).containsExactly(BlockVector3.at(9, 64, 9)); + + StrokeContext otherStroke = brush.prepare(other, true, feedback, stroke().target(BlockVector3.at(6, 64, 6)).build()); + assertThat(otherStroke.points()).hasSize(2); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/objects/brush/ShapeBrushesTest.java b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/ShapeBrushesTest.java new file mode 100644 index 00000000..11080bbe --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/ShapeBrushesTest.java @@ -0,0 +1,191 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.objects.brush; + +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.bukkit.Axis; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static net.onelitefeather.bettergopaint.support.BrushAssertions.sphereOf; +import static net.onelitefeather.bettergopaint.support.StrokeBuilder.stroke; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The brushes that shape their result from a sphere around the target. + */ +class ShapeBrushesTest { + + private final Terrain terrain = new Terrain(); + private final BlockVector3 target = Terrain.surface(); + + private Set painted(MemoryExtent extent) { + return extent.positionsOf(terrain.gold); + } + + @Test + @DisplayName("sphere brush paints every solid block of the sphere and nothing else") + void sphereBrush() { + MemoryExtent extent = terrain.flat(); + StrokeContext context = stroke().blocks(terrain.gold).size(6).build(); + + new SphereBrush().apply(extent, context); + + assertThat(painted(extent)).isEqualTo(sphereOf(extent, target, 6, terrain.gold)); + assertThat(painted(extent)).isNotEmpty().allMatch(position -> position.y() <= Terrain.SURFACE); + assertThat(extent.written()).isEqualTo(painted(extent)); + } + + @Test + @DisplayName("sphere brush skips air") + void sphereBrushSkipsAir() { + MemoryExtent extent = terrain.empty(); + + new SphereBrush().apply(extent, stroke().blocks(terrain.gold).build()); + + assertThat(extent.written()).isEmpty(); + } + + @Test + @DisplayName("spray brush paints a share of the sphere that follows the chance") + void sprayBrush() { + Set sphere = sphereOf(terrain.flat(), target, 8, terrain.stone); + + MemoryExtent none = terrain.flat(); + new SprayBrush().apply(none, stroke().blocks(terrain.gold).size(8).chance(0).build()); + MemoryExtent all = terrain.flat(); + new SprayBrush().apply(all, stroke().blocks(terrain.gold).size(8).chance(100).build()); + MemoryExtent half = terrain.flat(); + new SprayBrush().apply(half, stroke().blocks(terrain.gold).size(8).chance(50).build()); + + assertThat(painted(none)).isEmpty(); + assertThat(painted(all)).isEqualTo(sphere); + assertThat(painted(half)).isNotEmpty().isSubsetOf(sphere).hasSizeBetween(sphere.size() / 4, sphere.size() * 3 / 4); + } + + @Test + @DisplayName("spray brush with the same seed paints the same blocks") + void sprayBrushIsDeterministic() { + MemoryExtent first = terrain.flat(); + MemoryExtent second = terrain.flat(); + MemoryExtent other = terrain.flat(); + + new SprayBrush().apply(first, stroke().blocks(terrain.gold).size(8).seed(5).build()); + new SprayBrush().apply(second, stroke().blocks(terrain.gold).size(8).seed(5).build()); + new SprayBrush().apply(other, stroke().blocks(terrain.gold).size(8).seed(6).build()); + + assertThat(painted(first)).isEqualTo(painted(second)); + assertThat(painted(first)).isNotEqualTo(painted(other)); + } + + @Test + @DisplayName("splatter brush without falloff paints the whole sphere") + void splatterWithoutFalloff() { + MemoryExtent extent = terrain.flat(); + + new SplatterBrush().apply(extent, stroke().blocks(terrain.gold).size(8).falloff(0).build()); + + assertThat(painted(extent)).isEqualTo(sphereOf(extent, target, 8, terrain.gold)).isNotEmpty(); + } + + @Test + @DisplayName("splatter brush with full falloff paints more blocks near the target") + void splatterWithFalloff() { + MemoryExtent extent = terrain.flat(); + Set sphere = sphereOf(extent, target, 12, terrain.stone); + + new SplatterBrush().apply(extent, stroke().blocks(terrain.gold).size(12).falloff(100).build()); + + Set painted = painted(extent); + assertThat(painted).isNotEmpty().isSubsetOf(sphere).hasSizeLessThan(sphere.size()); + double paintedDistance = painted.stream().mapToDouble(position -> position.distance(target)).average().orElseThrow(); + double sphereDistance = sphere.stream().mapToDouble(position -> position.distance(target)).average().orElseThrow(); + assertThat(paintedDistance).isLessThan(sphereDistance); + } + + @Test + @DisplayName("disc brush paints one layer of the sphere on the axis") + void discBrush() { + for (Axis axis : Axis.values()) { + MemoryExtent extent = terrain.flat(); + StrokeContext context = stroke().blocks(terrain.gold).size(8).axis(axis).build(); + + new DiscBrush().apply(extent, context); + + Set painted = painted(extent); + assertThat(painted).as("disc on %s", axis).isNotEmpty() + .allMatch(position -> switch (axis) { + case X -> position.x() == target.x(); + case Y -> position.y() == target.y(); + case Z -> position.z() == target.z(); + }); + assertThat(painted).isSubsetOf(sphereOf(extent, target, 8, terrain.gold)); + } + } + + @Test + @DisplayName("disc brush on the y axis covers the full circle of the surface layer") + void discBrushCircle() { + MemoryExtent extent = terrain.flat(); + + new DiscBrush().apply(extent, stroke().blocks(terrain.gold).size(8).axis(Axis.Y).build()); + + assertThat(painted(extent)).contains(target, target.add(3, 0, 0), target.add(0, 0, -3), target.add(-2, 0, 2)) + .doesNotContain(target.add(4, 0, 0), target.add(3, 0, 3)); + } + + @Test + @DisplayName("bucket brush paints connected blocks of the target type only") + void bucketBrush() { + MemoryExtent extent = terrain.flat() + .set(0, Terrain.SURFACE, 0, terrain.dirt) + .set(1, Terrain.SURFACE, 0, terrain.dirt) + .set(3, Terrain.SURFACE, 0, terrain.dirt); + + new BucketBrush().apply(extent, stroke().blocks(terrain.gold).size(10).build()); + + assertThat(painted(extent)).containsExactlyInAnyOrder(target, target.add(1, 0, 0)); + assertThat(extent.typeAt(3, Terrain.SURFACE, 0)).isSameAs(terrain.dirt); + } + + @Test + @DisplayName("bucket brush on plain ground fills the connected part of the sphere") + void bucketBrushOnGround() { + MemoryExtent extent = terrain.flat(); + + new BucketBrush().apply(extent, stroke().blocks(terrain.gold).size(6).build()); + + assertThat(painted(extent)).isEqualTo(sphereOf(extent, target, 6, terrain.gold)).isNotEmpty(); + } + + @Test + @DisplayName("the brushes keep their names") + void names() { + assertThat(new SphereBrush().getName()).isEqualTo("Sphere Brush"); + assertThat(new BucketBrush().getName()).isEqualTo("Bucket Brush"); + assertThat(new PaintBrush(SettingsHolder.of(net.onelitefeather.bettergopaint.settings.Settings.defaults())).getName()) + .isEqualTo("Paint Brush"); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/objects/brush/StrokeRulesTest.java b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/StrokeRulesTest.java new file mode 100644 index 00000000..6b3b454a --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/StrokeRulesTest.java @@ -0,0 +1,200 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.objects.brush; + +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.function.mask.Mask; +import net.onelitefeather.bettergopaint.brush.SingleTypeMask; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static net.onelitefeather.bettergopaint.support.BrushAssertions.sphereOf; +import static net.onelitefeather.bettergopaint.support.StrokeBuilder.stroke; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The rules every stroke follows, independent of the brush: palette, mask, the session mask and threads. + */ +class StrokeRulesTest { + + private final Terrain terrain = new Terrain(); + private final BlockVector3 target = Terrain.surface(); + + @Test + @DisplayName("a stroke only places blocks of the palette") + void placesOnlyPaletteBlocks() { + MemoryExtent extent = terrain.flat(); + + new SphereBrush().apply(extent, stroke().blocks(terrain.gold, terrain.andesite).size(10).build()); + + Set placed = extent.written(); + assertThat(placed).isNotEmpty(); + assertThat(placed).allMatch(position -> extent.typeAt(position).equals(terrain.gold) + || extent.typeAt(position).equals(terrain.andesite)); + } + + @Test + @DisplayName("every block of the palette is chosen about equally often") + void paletteIsEvenlyWeighted() { + MemoryExtent extent = terrain.flat(); + + new SphereBrush().apply(extent, stroke().blocks(terrain.gold, terrain.andesite).size(16).seed(3).build()); + + int gold = extent.positionsOf(terrain.gold).size(); + int andesite = extent.positionsOf(terrain.andesite).size(); + assertThat(gold).isGreaterThan(100); + assertThat(andesite).isGreaterThan(100); + assertThat((double) gold / (gold + andesite)).isBetween(0.4, 0.6); + } + + private MemoryExtent grassAndDirt() { + MemoryExtent extent = terrain.flat() + .fill(BlockVector3.at(-12, Terrain.SURFACE, -12), BlockVector3.at(-2, Terrain.SURFACE, 12), terrain.grass) + .fill(BlockVector3.at(-1, Terrain.SURFACE, -12), BlockVector3.at(12, Terrain.SURFACE, 12), terrain.dirt); + return extent.set(0, Terrain.SURFACE, 0, terrain.grass); + } + + @Test + @DisplayName("with the mask enabled only blocks of the mask type change") + void maskLimitsTheStroke() { + MemoryExtent extent = grassAndDirt(); + Set grass = sphereOf(grassAndDirt(), target, 10, terrain.grass); + Mask mask = new SingleTypeMask(terrain.grass); + StrokeContext context = stroke().blocks(terrain.gold, terrain.andesite).size(10).mask(mask).build(); + + new SphereBrush().apply(extent, context); + + assertThat(grass).contains(target).hasSizeGreaterThan(1); + assertThat(extent.written()).isEqualTo(grass); + assertThat(extent.typeAt(1, Terrain.SURFACE, 0)).isSameAs(terrain.dirt); + assertThat(extent.typeAt(0, Terrain.SURFACE - 1, 0)).isSameAs(terrain.stone); + } + + @Test + @DisplayName("overlay, underlay and fracture brushes respect the mask as well") + void maskAppliesToEveryBrush() { + Mask nothing = new SingleTypeMask(terrain.grass); + + for (Brush brush : new Brush[]{new OverlayBrush(), new UnderlayBrush(), new FractureBrush(), new AngleBrush(), + new SprayBrush(), new SplatterBrush(), new DiscBrush(), new BucketBrush(), new GradientBrush()}) { + MemoryExtent extent = terrain.step(); + brush.apply(extent, stroke().blocks(terrain.gold).size(8).mask(nothing).chance(100).build()); + assertThat(extent.written()).as(brush.getName()).isEmpty(); + } + } + + @Test + @DisplayName("blocks the mask of the session rejects stay untouched") + void sessionMaskBlocksChanges() { + MemoryExtent extent = terrain.flatWithTop(terrain.grass); + Mask sessionMask = new SingleTypeMask(extent, terrain.grass); + + new SphereBrush().apply(Brush.masked(extent, sessionMask), stroke().blocks(terrain.gold).size(8).build()); + + assertThat(extent.positionsOf(terrain.gold)).isNotEmpty().allMatch(position -> position.y() == Terrain.SURFACE); + assertThat(extent.typeAt(0, Terrain.SURFACE - 1, 0)).isSameAs(terrain.stone); + assertThat(extent.typeAt(0, Terrain.SURFACE, 0)).isSameAs(terrain.gold); + } + + @Test + @DisplayName("without a session mask every block of the stroke changes") + void noSessionMask() { + MemoryExtent extent = terrain.flatWithTop(terrain.grass); + + new SphereBrush().apply(Brush.masked(extent, null), stroke().blocks(terrain.gold).size(8).build()); + + assertThat(extent.typeAt(0, Terrain.SURFACE - 1, 0)).isSameAs(terrain.gold); + } + + @Test + @DisplayName("the surface mode decides which blocks of the sphere are painted") + void surfaceModeApplies() { + MemoryExtent extent = terrain.flat(); + + new SphereBrush().apply(extent, stroke().blocks(terrain.gold).size(8).surfaceMode(SurfaceMode.DIRECT).build()); + + assertThat(extent.written()).isNotEmpty().allMatch(position -> position.y() == Terrain.SURFACE); + } + + @Test + @DisplayName("a stroke reads before it writes, so earlier writes cannot change which blocks are chosen") + void readsBeforeWrites() { + MemoryExtent extent = terrain.flat(); + + // Underlay picks blocks with a solid block above. The palette is air, so every write makes the + // block above the next layer non-solid. The layers are visited from the bottom up: a brush that + // read after writing would stop after the first layer it removed. + new UnderlayBrush().apply(extent, stroke().blocks(terrain.air).size(8).thickness(1).build()); + + Set expected = sphereOf(terrain.flat(), target, 8, terrain.stone).stream() + .filter(position -> position.y() < Terrain.SURFACE) + .collect(java.util.stream.Collectors.toSet()); + assertThat(expected).isNotEmpty(); + assertThat(extent.written()).isEqualTo(expected); + } + + @Test + @DisplayName("the same seed changes the same blocks no matter which thread runs the stroke") + void sameResultOnAnyThread() throws Exception { + MemoryExtent local = terrain.flat(); + MemoryExtent remote = terrain.flat(); + Brush brush = new SprayBrush(); + + brush.apply(local, stroke().blocks(terrain.gold, terrain.andesite).size(10).chance(60).seed(42).build()); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + executor.submit(() -> brush.apply(remote, + stroke().blocks(terrain.gold, terrain.andesite).size(10).chance(60).seed(42).build())).get(30, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + + assertThat(remote.positionsOf(terrain.gold)).isNotEmpty().isEqualTo(local.positionsOf(terrain.gold)); + assertThat(remote.positionsOf(terrain.andesite)).isNotEmpty().isEqualTo(local.positionsOf(terrain.andesite)); + } + + @Test + @DisplayName("every brush paints deterministically for a fixed seed") + void everyBrushIsDeterministic() { + for (Brush brush : new Brush[]{new SphereBrush(), new SprayBrush(), new SplatterBrush(), new DiscBrush(), + new BucketBrush(), new AngleBrush(), new OverlayBrush(), new UnderlayBrush(), new FractureBrush(), + new GradientBrush()}) { + MemoryExtent first = terrain.step(); + MemoryExtent second = terrain.step(); + brush.apply(first, stroke().blocks(terrain.gold, terrain.andesite, terrain.dirt).size(10).falloff(50).mixing(40) + .chance(50).seed(11).angle(1, 30).build()); + brush.apply(second, stroke().blocks(terrain.gold, terrain.andesite, terrain.dirt).size(10).falloff(50).mixing(40) + .chance(50).seed(11).angle(1, 30).build()); + + assertThat(second.written()).as(brush.getName()).isEqualTo(first.written()); + for (var type : new com.sk89q.worldedit.world.block.BlockType[]{terrain.gold, terrain.andesite, terrain.dirt}) { + assertThat(second.positionsOf(type)).as(brush.getName()).isEqualTo(first.positionsOf(type)); + } + } + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/objects/brush/TerrainBrushesTest.java b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/TerrainBrushesTest.java new file mode 100644 index 00000000..8f35a513 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/objects/brush/TerrainBrushesTest.java @@ -0,0 +1,222 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.objects.brush; + +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.UUID; + +import static net.onelitefeather.bettergopaint.support.BrushAssertions.sphereOf; +import static net.onelitefeather.bettergopaint.support.StrokeBuilder.stroke; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The brushes that look at the shape of the terrain around the target. + */ +class TerrainBrushesTest { + + private final Terrain terrain = new Terrain(); + private final BlockVector3 target = Terrain.surface(); + + private Set painted(MemoryExtent extent) { + return extent.positionsOf(terrain.gold); + } + + @Test + @DisplayName("angle brush leaves flat ground alone") + void angleBrushOnFlatGround() { + MemoryExtent extent = terrain.flat(); + + new AngleBrush().apply(extent, stroke().blocks(terrain.gold).size(8).build()); + + assertThat(extent.written()).isEmpty(); + } + + @Test + @DisplayName("angle brush paints only the blocks on the edge of a cliff") + void angleBrushOnACliff() { + MemoryExtent extent = terrain.step(); + + new AngleBrush().apply(extent, stroke().blocks(terrain.gold).size(8).angle(1, 45).build()); + + assertThat(painted(extent)).isNotEmpty() + .allMatch(position -> position.x() == 0 || position.x() == 1) + .isSubsetOf(sphereOf(extent, target, 8, terrain.gold)); + } + + @Test + @DisplayName("angle brush ignores a cliff that is flatter than the wanted angle") + void angleBrushNeedsASteepEnoughCliff() { + MemoryExtent extent = terrain.step(); + + new AngleBrush().apply(extent, stroke().blocks(terrain.gold).size(8).angle(1, 80).build()); + + assertThat(extent.written()).isEmpty(); + } + + @Test + @DisplayName("overlay brush paints the blocks that have air above them") + void overlayBrush() { + MemoryExtent extent = terrain.flat(); + + new OverlayBrush().apply(extent, stroke().blocks(terrain.gold).size(8).thickness(1).build()); + + assertThat(painted(extent)).isNotEmpty().allMatch(position -> position.y() == Terrain.SURFACE) + .isEqualTo(sphereOf(extent, target, 8, terrain.gold)); + } + + @Test + @DisplayName("overlay brush with a larger thickness reaches that many layers down") + void overlayBrushThickness() { + MemoryExtent extent = terrain.flat(); + + new OverlayBrush().apply(extent, stroke().blocks(terrain.gold).size(8).thickness(2).build()); + + assertThat(painted(extent)).isNotEmpty() + .allMatch(position -> position.y() >= Terrain.SURFACE - 1) + .anyMatch(position -> position.y() == Terrain.SURFACE - 1) + .anyMatch(position -> position.y() == Terrain.SURFACE); + } + + @Test + @DisplayName("underlay brush paints the blocks that are covered") + void underlayBrush() { + MemoryExtent extent = terrain.flat(); + + new UnderlayBrush().apply(extent, stroke().blocks(terrain.gold).size(8).thickness(1).build()); + + assertThat(painted(extent)).isNotEmpty() + .allMatch(position -> position.y() <= Terrain.SURFACE - 1) + .anyMatch(position -> position.y() == Terrain.SURFACE - 1); + assertThat(extent.typeAt(0, Terrain.SURFACE, 0)).isSameAs(terrain.stone); + } + + @Test + @DisplayName("underlay brush with a larger thickness needs that many covering layers") + void underlayBrushThickness() { + MemoryExtent extent = terrain.flat(); + + new UnderlayBrush().apply(extent, stroke().blocks(terrain.gold).size(8).thickness(2).build()); + + assertThat(painted(extent)).isNotEmpty().allMatch(position -> position.y() <= Terrain.SURFACE - 2); + } + + @Test + @DisplayName("fracture brush leaves flat ground alone") + void fractureBrushOnFlatGround() { + MemoryExtent extent = terrain.flat(); + + new FractureBrush().apply(extent, stroke().blocks(terrain.gold).size(8).build()); + + assertThat(extent.written()).isEmpty(); + } + + @Test + @DisplayName("fracture brush paints the blocks inside a crack") + void fractureBrushInATrench() { + MemoryExtent extent = terrain.trench(); + + new FractureBrush().apply(extent, stroke().blocks(terrain.gold).size(8).target(BlockVector3.at(0, Terrain.SURFACE - 3, 0)) + .fractureDistance(2).build()); + + assertThat(painted(extent)).isNotEmpty() + .allMatch(position -> position.x() == 0) + .contains(BlockVector3.at(0, Terrain.SURFACE - 3, 0)); + assertThat(extent.typeAt(1, Terrain.SURFACE - 3, 0)).isSameAs(terrain.stone); + } + + @Test + @DisplayName("gradient brush lays the palette out from the bottom of the sphere to the top") + void gradientBrush() { + MemoryExtent extent = terrain.flat(); + StrokeContext context = stroke().blocks(terrain.dirt, terrain.andesite, terrain.gold).size(6).mixing(0).falloff(0) + .build(); + + new GradientBrush().apply(extent, context); + + // The sphere of size 6 around y = 63 reaches down to y = 61 (y = 60 is exactly 3 blocks away). + // The gradient starts at y = 60, so the block index is (y - 60) / 6 * 3 rounded down. + assertThat(extent.typeAt(0, 60, 0)).isSameAs(terrain.stone); + assertThat(extent.typeAt(0, 61, 0)).isSameAs(terrain.dirt); + assertThat(extent.typeAt(0, 62, 0)).isSameAs(terrain.andesite); + assertThat(extent.typeAt(0, 63, 0)).isSameAs(terrain.andesite); + assertThat(extent.written()).hasSize(sphereOf(terrain.flat(), target, 6, terrain.stone).size()); + } + + @Test + @DisplayName("gradient brush with a single block paints that block") + void gradientBrushSingleBlock() { + MemoryExtent extent = terrain.flat(); + + new GradientBrush().apply(extent, stroke().blocks(terrain.gold).size(6).mixing(100).falloff(0).build()); + + assertThat(painted(extent)).isEqualTo(sphereOf(terrain.flat(), target, 6, terrain.stone)); + } + + @Test + @DisplayName("paint brush with a single point paints the sphere around it") + void paintBrushSinglePoint() { + MemoryExtent extent = terrain.flat(); + + new PaintBrush(SettingsHolder.of(Settings.defaults())) + .apply(extent, stroke().blocks(terrain.gold).size(6).build()); + + assertThat(painted(extent)).isEqualTo(sphereOf(terrain.flat(), target, 6, terrain.stone)); + } + + @Test + @DisplayName("paint brush with several points follows the curve") + void paintBrushCurve() { + MemoryExtent single = terrain.flat(); + MemoryExtent curve = terrain.flat(); + BlockVector3 second = target.add(6, 0, 0); + PaintBrush brush = new PaintBrush(SettingsHolder.of(Settings.defaults())); + + brush.apply(single, stroke().blocks(terrain.gold).size(4).build()); + brush.apply(curve, stroke().blocks(terrain.gold).size(4).points(target, second).target(second).build()); + + assertThat(painted(curve)).containsAll(painted(single)).hasSizeGreaterThan(painted(single).size()); + assertThat(painted(curve)).allMatch(position -> position.y() <= Terrain.SURFACE); + } + + @Test + @DisplayName("paint brush only collects points until the player sneaks") + void paintBrushCollectsPoints() { + PaintBrush brush = new PaintBrush(SettingsHolder.of(Settings.defaults())); + UUID player = UUID.randomUUID(); + net.kyori.adventure.audience.Audience feedback = net.kyori.adventure.audience.Audience.empty(); + StrokeContext first = stroke().blocks(terrain.gold).build(); + StrokeContext second = stroke().blocks(terrain.gold).target(target.add(4, 0, 0)).build(); + + assertThat(brush.prepare(player, false, feedback, first)).isNull(); + StrokeContext result = brush.prepare(player, true, feedback, second); + + assertThat(result).isNotNull(); + assertThat(result.points()).containsExactly(target, target.add(4, 0, 0)); + assertThat(brush.prepare(player, true, feedback, first).points()).containsExactly(target); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/service/UpdateServiceTest.java b/src/test/java/net/onelitefeather/bettergopaint/service/UpdateServiceTest.java new file mode 100644 index 00000000..f7d9fdff --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/service/UpdateServiceTest.java @@ -0,0 +1,91 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.service; + +import com.github.zafarkhaja.semver.Version; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.logger.slf4j.ComponentLogger; +import net.onelitefeather.bettergopaint.settings.Settings; +import net.onelitefeather.bettergopaint.settings.SettingsHolder; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class UpdateServiceTest { + + private final Plugin plugin = mock(Plugin.class); + private final ComponentLogger console = mock(ComponentLogger.class); + + private UpdateService service(Optional answer) { + return new UpdateService(plugin, console, SettingsHolder.of(Settings.defaults()), Version.parse("2.0.0"), () -> answer); + } + + @Test + @DisplayName("a newer release is remembered and reported to the console once") + void newerReleaseIsReportedOnce() { + UpdateService service = service(Optional.of("2.1.0\n")); + + service.check(); + service.check(); + + assertThat(service.remoteVersion()).contains(Version.parse("2.1.0")); + verify(console, times(1)).warn(any(Component.class)); + } + + @Test + @DisplayName("the installed or an older release is not reported") + void olderReleaseIsIgnored() { + UpdateService service = service(Optional.of("1.9.9")); + + service.check(); + + assertThat(service.remoteVersion()).isEmpty(); + verify(console, never()).warn(any(Component.class)); + } + + @Test + @DisplayName("an answer that is not a version is ignored without an exception") + void garbageIsIgnored() { + UpdateService service = service(Optional.of("Service Unavailable")); + + service.check(); + + assertThat(service.remoteVersion()).isEmpty(); + verify(console, never()).warn(any(Component.class)); + } + + @Test + @DisplayName("an unreachable Hangar is ignored") + void unreachableIsIgnored() { + UpdateService service = service(Optional.empty()); + + service.check(); + + assertThat(service.remoteVersion()).isEmpty(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/settings/SettingsHolderTest.java b/src/test/java/net/onelitefeather/bettergopaint/settings/SettingsHolderTest.java new file mode 100644 index 00000000..5daa5df4 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/settings/SettingsHolderTest.java @@ -0,0 +1,110 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.settings; + +import org.bukkit.Axis; +import org.bukkit.Material; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SettingsHolderTest { + + @TempDir + Path folder; + + @Test + @DisplayName("a missing config.yml is written with every key and its default") + void writesDefaults() throws IOException { + Path file = folder.resolve("config.yml"); + + SettingsHolder holder = new SettingsHolder(file); + + assertThat(holder.get()).isEqualTo(Settings.defaults()); + assertThat(Files.readString(file)) + .contains("max-size: 100", "default-brush: FEATHER", "check-for-updates: true", "max-fracture-distance: 5") + .contains("# Largest brush size a player can set"); + } + + @Test + @DisplayName("values from config.yml are read") + void readsValues() throws IOException { + Path file = folder.resolve("config.yml"); + Files.writeString(file, """ + generic: + max-size: 40 + default-axis: x + default-brush: BLAZE_ROD + disabled-worlds: + - world_nether + angle: + max-angle-height-difference: 70.5 + """); + + Settings settings = new SettingsHolder(file).get(); + + assertThat(settings.generic().maxSize()).isEqualTo(40); + assertThat(settings.generic().defaultAxis()).isEqualTo(Axis.X); + assertThat(settings.generic().defaultBrush()).isEqualTo(Material.BLAZE_ROD); + assertThat(settings.generic().disabledWorlds()).containsExactly("world_nether"); + assertThat(settings.angle().maxAngleHeightDifference()).isEqualTo(70.5); + } + + @Test + @DisplayName("invalid values fall back to their default") + void invalidValuesUseDefaults() throws IOException { + Path file = folder.resolve("config.yml"); + Files.writeString(file, """ + generic: + max-size: huge + default-axis: W + default-mask: NOT_A_BLOCK + check-for-updates: maybe + """); + + Settings.Generic generic = new SettingsHolder(file).get().generic(); + Settings.Generic defaults = Settings.defaults().generic(); + + assertThat(generic.maxSize()).isEqualTo(defaults.maxSize()); + assertThat(generic.defaultAxis()).isEqualTo(defaults.defaultAxis()); + assertThat(generic.defaultMask()).isEqualTo(defaults.defaultMask()); + assertThat(generic.checkForUpdates()).isEqualTo(defaults.checkForUpdates()); + } + + @Test + @DisplayName("reload publishes a new snapshot without touching the old one") + void reloadSwapsSnapshot() throws IOException { + Path file = folder.resolve("config.yml"); + SettingsHolder holder = new SettingsHolder(file); + Settings before = holder.get(); + + Files.writeString(file, Files.readString(file).replace("max-size: 100", "max-size: 25")); + Settings after = holder.reload(); + + assertThat(after.generic().maxSize()).isEqualTo(25); + assertThat(holder.get()).isSameAs(after); + assertThat(before.generic().maxSize()).isEqualTo(100); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/BrushAssertions.java b/src/test/java/net/onelitefeather/bettergopaint/support/BrushAssertions.java new file mode 100644 index 00000000..0b839414 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/BrushAssertions.java @@ -0,0 +1,61 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Helpers that describe the expected result of a brush without using the code under test. + */ +public final class BrushAssertions { + + private BrushAssertions() { + } + + /** + * Every position in the box around the middle whose distance to the middle is below half the size. + */ + public static Set sphere(BlockVector3 middle, int size) { + Set result = new LinkedHashSet<>(); + int reach = size / 2 + 1; + for (int x = -reach; x <= reach; x++) { + for (int y = -reach; y <= reach; y++) { + for (int z = -reach; z <= reach; z++) { + if (Math.sqrt(x * x + y * y + z * z) < size / 2d) { + result.add(middle.add(x, y, z)); + } + } + } + } + return result; + } + + /** + * The positions of the sphere that hold a block of the given type. + */ + public static Set sphereOf(MemoryExtent extent, BlockVector3 middle, int size, BlockType type) { + Set result = sphere(middle, size); + result.removeIf(position -> !extent.typeAt(position).equals(type)); + return result; + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtent.java b/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtent.java new file mode 100644 index 00000000..42fdce19 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtent.java @@ -0,0 +1,182 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.fastasyncworldedit.core.nbt.FaweCompoundTag; +import com.sk89q.worldedit.WorldEditException; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.function.operation.Operation; +import com.sk89q.worldedit.math.BlockVector2; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.biome.BiomeType; +import com.sk89q.worldedit.world.block.BaseBlock; +import com.sk89q.worldedit.world.block.BlockState; +import com.sk89q.worldedit.world.block.BlockStateHolder; +import com.sk89q.worldedit.world.block.BlockType; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * A WorldEdit {@link Extent} that keeps its blocks in a map. Every position that was not set is air. + * Block types are stored, because the tests never look at block properties. + */ +public final class MemoryExtent implements Extent { + + private final Map blocks = new LinkedHashMap<>(); + private final Map writes = new LinkedHashMap<>(); + private final BlockType air; + private final int minY; + private final int maxY; + + public MemoryExtent(BlockType air, int minY, int maxY) { + this.air = air; + this.minY = minY; + this.maxY = maxY; + } + + /** + * Fills the box between the two corners, both inclusive, with the type. + */ + public MemoryExtent fill(BlockVector3 from, BlockVector3 to, BlockType type) { + for (int x = from.x(); x <= to.x(); x++) { + for (int y = from.y(); y <= to.y(); y++) { + for (int z = from.z(); z <= to.z(); z++) { + blocks.put(BlockVector3.at(x, y, z), type); + } + } + } + return this; + } + + public MemoryExtent set(int x, int y, int z, BlockType type) { + blocks.put(BlockVector3.at(x, y, z), type); + return this; + } + + public BlockType typeAt(BlockVector3 position) { + return blocks.getOrDefault(position, air); + } + + public BlockType typeAt(int x, int y, int z) { + return typeAt(BlockVector3.at(x, y, z)); + } + + /** + * All positions that hold the type, in insertion order. + */ + public Set positionsOf(BlockType type) { + Set result = new java.util.LinkedHashSet<>(); + blocks.forEach((position, value) -> { + if (value.equals(type)) { + result.add(position); + } + }); + return result; + } + + /** + * How often a position was written through {@link #setBlock}. + */ + public int writeCount(BlockVector3 position) { + return writes.getOrDefault(position, 0); + } + + /** + * The positions that were written through {@link #setBlock}. + */ + public Set written() { + return writes.keySet(); + } + + @Override + public BlockVector3 getMinimumPoint() { + return BlockVector3.at(-30000000, minY, -30000000); + } + + @Override + public BlockVector3 getMaximumPoint() { + return BlockVector3.at(30000000, maxY, 30000000); + } + + @Override + public BlockState getBlock(BlockVector3 position) { + return typeAt(position).getDefaultState(); + } + + @Override + public BlockState getBlock(int x, int y, int z) { + return typeAt(x, y, z).getDefaultState(); + } + + @Override + public BaseBlock getFullBlock(BlockVector3 position) { + throw new UnsupportedOperationException("not needed by the brushes"); + } + + @Override + public BiomeType getBiome(BlockVector3 position) { + return null; + } + + @Override + public BiomeType getBiomeType(int x, int y, int z) { + return null; + } + + @Override + public BiomeType getBiome(BlockVector2 position) { + return null; + } + + @Override + public > boolean setBlock(BlockVector3 position, T block) throws WorldEditException { + BlockType previous = typeAt(position); + BlockType type = block.getBlockType(); + writes.merge(position.toImmutable(), 1, Integer::sum); + blocks.put(position.toImmutable(), type); + return !previous.equals(type); + } + + @Override + public > boolean setBlock(int x, int y, int z, T block) throws WorldEditException { + return setBlock(BlockVector3.at(x, y, z), block); + } + + @Override + public boolean tile(int x, int y, int z, FaweCompoundTag tile) throws WorldEditException { + return false; + } + + @Override + public boolean setBiome(BlockVector3 position, BiomeType biome) { + return false; + } + + @Override + public boolean setBiome(int x, int y, int z, BiomeType biome) { + return false; + } + + @Override + public Operation commit() { + return null; + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtentTest.java b/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtentTest.java new file mode 100644 index 00000000..74861c3e --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/MemoryExtentTest.java @@ -0,0 +1,75 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MemoryExtentTest { + + private final Terrain terrain = new Terrain(); + + @Test + @DisplayName("a block that was set can be read back") + void roundTripsABlock() { + MemoryExtent extent = terrain.empty(); + BlockVector3 position = BlockVector3.at(3, 70, -4); + + boolean changed = extent.setBlock(position, terrain.gold.getDefaultState()); + + assertThat(changed).isTrue(); + assertThat(extent.getBlock(position).getBlockType()).isSameAs(terrain.gold); + assertThat(extent.getBlock(3, 70, -4).getBlockType()).isSameAs(terrain.gold); + assertThat(extent.writeCount(position)).isEqualTo(1); + } + + @Test + @DisplayName("positions that were never set are air") + void unsetPositionsAreAir() { + MemoryExtent extent = terrain.empty(); + + BlockType type = extent.getBlock(BlockVector3.at(1, 2, 3)).getBlockType(); + + assertThat(type).isSameAs(terrain.air); + assertThat(type.getMaterial().isAir()).isTrue(); + } + + @Test + @DisplayName("setting the same type again reports no change") + void sameTypeIsNoChange() { + MemoryExtent extent = terrain.empty().set(0, 64, 0, terrain.stone); + + boolean changed = extent.setBlock(BlockVector3.at(0, 64, 0), terrain.stone.getDefaultState()); + + assertThat(changed).isFalse(); + } + + @Test + @DisplayName("the build limits are the ones given") + void reportsBuildLimits() { + MemoryExtent extent = terrain.empty(); + + assertThat(extent.getMinY()).isEqualTo(Terrain.MIN_Y); + assertThat(extent.getMaxY()).isEqualTo(Terrain.MAX_Y); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/StrokeBuilder.java b/src/test/java/net/onelitefeather/bettergopaint/support/StrokeBuilder.java new file mode 100644 index 00000000..1b84a7e6 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/StrokeBuilder.java @@ -0,0 +1,161 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.fastasyncworldedit.core.math.random.SimpleRandom; +import com.sk89q.worldedit.function.mask.Mask; +import com.sk89q.worldedit.function.pattern.RandomPattern; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; +import com.sk89q.worldedit.world.block.BlockType; +import net.onelitefeather.bettergopaint.brush.StrokeContext; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import org.bukkit.Axis; + +import java.util.List; +import java.util.Random; + +/** + * Builds {@link StrokeContext}s for tests. Every value has a plain default, the random source is seeded. + */ +public final class StrokeBuilder { + + private BlockVector3 target = Terrain.surface(); + private List points; + private Vector3 eye; + private List blocks = List.of(); + private Mask mask; + private Axis axis = Axis.Y; + private SurfaceMode surfaceMode = SurfaceMode.DISABLED; + private int size = 6; + private int chance = 50; + private int falloffStrength = 0; + private int mixingStrength = 0; + private int thickness = 1; + private int fractureDistance = 2; + private int angleDistance = 1; + private double angleHeightDifference = 45; + private long seed = 1; + + public static StrokeBuilder stroke() { + return new StrokeBuilder(); + } + + public StrokeBuilder target(BlockVector3 target) { + this.target = target; + return this; + } + + public StrokeBuilder points(BlockVector3... points) { + this.points = List.of(points); + return this; + } + + public StrokeBuilder eye(Vector3 eye) { + this.eye = eye; + return this; + } + + public StrokeBuilder blocks(BlockType... blocks) { + this.blocks = List.of(blocks); + return this; + } + + public StrokeBuilder mask(Mask mask) { + this.mask = mask; + return this; + } + + public StrokeBuilder axis(Axis axis) { + this.axis = axis; + return this; + } + + public StrokeBuilder surfaceMode(SurfaceMode surfaceMode) { + this.surfaceMode = surfaceMode; + return this; + } + + public StrokeBuilder size(int size) { + this.size = size; + return this; + } + + public StrokeBuilder chance(int chance) { + this.chance = chance; + return this; + } + + public StrokeBuilder falloff(int falloffStrength) { + this.falloffStrength = falloffStrength; + return this; + } + + public StrokeBuilder mixing(int mixingStrength) { + this.mixingStrength = mixingStrength; + return this; + } + + public StrokeBuilder thickness(int thickness) { + this.thickness = thickness; + return this; + } + + public StrokeBuilder fractureDistance(int fractureDistance) { + this.fractureDistance = fractureDistance; + return this; + } + + public StrokeBuilder angle(int distance, double heightDifference) { + this.angleDistance = distance; + this.angleHeightDifference = heightDifference; + return this; + } + + public StrokeBuilder seed(long seed) { + this.seed = seed; + return this; + } + + public StrokeContext build() { + Random random = new Random(seed); + SimpleRandom simpleRandom = (x, y, z) -> random.nextDouble(); + RandomPattern palette = new RandomPattern(simpleRandom); + blocks.forEach(block -> palette.add(TestBlocks.pattern(block), 1)); + return new StrokeContext( + target, + points == null ? List.of(target) : points, + eye == null ? Vector3.at(target.x(), target.y() + 1.5, target.z()) : eye, + palette, + blocks, + mask, + axis, + surfaceMode, + size, + chance, + falloffStrength, + mixingStrength, + thickness, + fractureDistance, + angleDistance, + angleHeightDifference, + random + ); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/Terrain.java b/src/test/java/net/onelitefeather/bettergopaint/support/Terrain.java new file mode 100644 index 00000000..206d68a6 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/Terrain.java @@ -0,0 +1,85 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BlockType; + +/** + * Builds the small worlds the brush tests paint on. All of them are flat or have a feature at the origin. + */ +public final class Terrain { + + public static final int MIN_Y = 0; + public static final int MAX_Y = 255; + /** + * Height of the top layer of {@link #flat}. + */ + public static final int SURFACE = 63; + private static final int RADIUS = 12; + + public final BlockType air = TestBlocks.air(); + public final BlockType stone = TestBlocks.solid("stone"); + public final BlockType grass = TestBlocks.solid("grass"); + public final BlockType dirt = TestBlocks.solid("dirt"); + public final BlockType andesite = TestBlocks.solid("andesite"); + public final BlockType gold = TestBlocks.solid("gold"); + + public MemoryExtent empty() { + return new MemoryExtent(air, MIN_Y, MAX_Y); + } + + /** + * A world of stone up to and including {@link #SURFACE}. + */ + public MemoryExtent flat() { + return empty().fill(BlockVector3.at(-RADIUS, MIN_Y, -RADIUS), BlockVector3.at(RADIUS, SURFACE, RADIUS), stone); + } + + /** + * A world of stone with a top layer of the given type at {@link #SURFACE}. + */ + public MemoryExtent flatWithTop(BlockType top) { + return flat().fill(BlockVector3.at(-RADIUS, SURFACE, -RADIUS), BlockVector3.at(RADIUS, SURFACE, RADIUS), top); + } + + /** + * Flat ground that is four blocks higher for every x greater than zero. + */ + public MemoryExtent step() { + return flat().fill(BlockVector3.at(1, SURFACE + 1, -RADIUS), BlockVector3.at(RADIUS, SURFACE + 4, RADIUS), stone); + } + + /** + * Flat ground with a one block wide trench along z at x = 0. The trench is three blocks deep. + */ + public MemoryExtent trench() { + MemoryExtent extent = flat(); + for (int y = SURFACE - 2; y <= SURFACE; y++) { + for (int z = -RADIUS; z <= RADIUS; z++) { + extent.set(0, y, z, air); + } + } + return extent; + } + + public static BlockVector3 surface() { + return BlockVector3.at(0, SURFACE, 0); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/support/TestBlocks.java b/src/test/java/net/onelitefeather/bettergopaint/support/TestBlocks.java new file mode 100644 index 00000000..01032fe2 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/support/TestBlocks.java @@ -0,0 +1,87 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.support; + +import com.sk89q.worldedit.function.pattern.Pattern; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.world.block.BaseBlock; +import com.sk89q.worldedit.world.block.BlockState; +import com.sk89q.worldedit.world.block.BlockType; +import com.sk89q.worldedit.world.registry.BlockMaterial; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Block types for tests without a running server. + *

+ * WorldEdit builds {@link BlockType}s from the block registry of the platform, which needs a Minecraft + * server, so {@code BlockTypes.STONE} cannot be initialised in a unit test. The brushes only ask a type + * for its default state and whether its material is air or solid, so a mock that answers those is enough. + * Mocks compare by identity, which makes two types equal exactly when they are the same instance. + */ +public final class TestBlocks { + + private TestBlocks() { + } + + /** + * Creates a block type. + * + * @param id the name used in assertion messages + * @param air whether the material is air + * @param solid whether the material is solid + */ + public static BlockType type(String id, boolean air, boolean solid) { + BlockType type = mock(BlockType.class, id); + BlockState state = mock(BlockState.class, id + " (state)"); + BlockMaterial material = mock(BlockMaterial.class, id + " (material)"); + when(material.isAir()).thenReturn(air); + when(material.isSolid()).thenReturn(solid); + when(type.getMaterial()).thenReturn(material); + when(type.getDefaultState()).thenReturn(state); + when(state.getBlockType()).thenReturn(type); + BaseBlock block = block(type); + when(type.applyBlock(any(BlockVector3.class))).thenReturn(block); + return type; + } + + public static BlockType air() { + return type("air", true, false); + } + + public static BlockType solid(String id) { + return type(id, false, true); + } + + /** + * Creates a pattern that always answers with the type. + */ + public static Pattern pattern(BlockType type) { + BaseBlock block = block(type); + return position -> block; + } + + private static BaseBlock block(BlockType type) { + BaseBlock block = mock(BaseBlock.class); + when(block.getBlockType()).thenReturn(type); + return block; + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/translations/TranslationBundleTest.java b/src/test/java/net/onelitefeather/bettergopaint/translations/TranslationBundleTest.java new file mode 100644 index 00000000..9961cf0f --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/translations/TranslationBundleTest.java @@ -0,0 +1,110 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.translations; + +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Guards the shipped language files: every key the code asks for has to exist, every language has + * to ship the same keys, and every message has to be valid MiniMessage. + */ +class TranslationBundleTest { + + private static final Path RESOURCES = Paths.get("src", "main", "resources"); + private static final Path SOURCES = Paths.get("src", "main", "java"); + private static final String BUNDLE = "bettergopaint"; + /** Every string literal that looks like a message key. Permission nodes live in Constants and are skipped. */ + private static final Pattern TRANSLATABLE = Pattern.compile("\"(bettergopaint\\.[a-z0-9_.]+)\""); + private static final String PERMISSIONS = "Constants.java"; + + static List bundles() throws IOException { + List files = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(RESOURCES, BUNDLE + "*.properties")) { + stream.forEach(files::add); + } + Collections.sort(files); + return files; + } + + private static Properties read(Path file) throws IOException { + Properties properties = new Properties(); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + properties.load(reader); + } + return properties; + } + + private static Set keysUsedInCode() throws IOException { + Set keys = new TreeSet<>(); + try (var files = Files.walk(SOURCES)) { + for (Path file : files.filter(path -> path.toString().endsWith(".java")) + .filter(path -> !path.getFileName().toString().equals(PERMISSIONS)) + .toList()) { + Matcher matcher = TRANSLATABLE.matcher(Files.readString(file)); + while (matcher.find()) { + keys.add(matcher.group(1)); + } + } + } + return keys; + } + + @ParameterizedTest(name = "{0}") + @MethodSource("bundles") + @DisplayName("every language ships every key the code uses") + void containsEveryKeyUsedInCode(Path bundle) throws IOException { + assertThat(read(bundle).stringPropertyNames()) + .as("keys of %s", bundle.getFileName()) + .containsAll(keysUsedInCode()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("bundles") + @DisplayName("every message is valid MiniMessage") + void messagesParse(Path bundle) throws IOException { + Properties properties = read(bundle); + for (String key : properties.stringPropertyNames()) { + assertThatCode(() -> MiniMessage.miniMessage().deserialize(properties.getProperty(key))) + .as("%s in %s", key, bundle.getFileName()) + .doesNotThrowAnyException(); + } + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocksTest.java b/src/test/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocksTest.java new file mode 100644 index 00000000..94167bd1 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/utils/ConnectedBlocksTest.java @@ -0,0 +1,81 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils; + +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConnectedBlocksTest { + + private final Terrain terrain = new Terrain(); + private final BlockVector3 start = Terrain.surface(); + + private MemoryExtent islands() { + return terrain.flat() + .set(0, Terrain.SURFACE, 0, terrain.dirt) + .set(1, Terrain.SURFACE, 0, terrain.dirt) + .set(3, Terrain.SURFACE, 0, terrain.dirt); + } + + @Test + @DisplayName("only blocks of the start type that touch each other are returned") + void followsTheStartType() { + MemoryExtent extent = islands(); + List candidates = Sphere.getBlocksInRadius(extent, start, 8, null, false).toList(); + + List connected = ConnectedBlocks.getConnectedBlocks(extent, start, candidates).toList(); + + assertThat(connected).containsExactlyInAnyOrder(start, BlockVector3.at(1, Terrain.SURFACE, 0)); + } + + @Test + @DisplayName("blocks outside the candidate list are not reached") + void staysInsideTheCandidates() { + MemoryExtent extent = islands(); + List candidates = List.of(start); + + List connected = ConnectedBlocks.getConnectedBlocks(extent, start, candidates).toList(); + + assertThat(connected).containsExactly(start); + } + + @Test + @DisplayName("a flood of stone spreads through the whole candidate list") + void floodsAllCandidates() { + MemoryExtent extent = terrain.flat(); + List candidates = Sphere.getBlocksInRadius(extent, start, 6, null, false).toList(); + + List connected = ConnectedBlocks.getConnectedBlocks(extent, start, candidates).toList(); + + assertThat(connected).containsExactlyInAnyOrderElementsOf(candidates); + } + + @Test + @DisplayName("no candidates means no result") + void emptyCandidates() { + assertThat(ConnectedBlocks.getConnectedBlocks(islands(), start, List.of())).isEmpty(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/utils/HeightTest.java b/src/test/java/net/onelitefeather/bettergopaint/utils/HeightTest.java new file mode 100644 index 00000000..62cfc968 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/utils/HeightTest.java @@ -0,0 +1,98 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils; + +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +class HeightTest { + + private final Terrain terrain = new Terrain(); + + @Test + @DisplayName("from inside the ground the nearest non-empty height is the first air above") + void searchesUpFromSolid() { + MemoryExtent extent = terrain.flat(); + + assertThat(Height.getNearestNonEmptyBlock(extent, BlockVector3.at(0, 40, 0))).isEqualTo(Terrain.SURFACE + 1); + assertThat(Height.getNearestNonEmptyBlock(extent, BlockVector3.at(0, Terrain.SURFACE, 0))) + .isEqualTo(Terrain.SURFACE + 1); + } + + @Test + @DisplayName("from the air the nearest non-empty height is the block above the ground") + void searchesDownFromAir() { + MemoryExtent extent = terrain.flat(); + + assertThat(Height.getNearestNonEmptyBlock(extent, BlockVector3.at(0, 90, 0))).isEqualTo(Terrain.SURFACE + 1); + } + + @Test + @DisplayName("an empty column ends on the minimum height") + void emptyColumn() { + assertThat(Height.getNearestNonEmptyBlock(terrain.empty(), BlockVector3.at(0, 90, 0))).isEqualTo(Terrain.MIN_Y); + } + + @Test + @DisplayName("a column that is solid up to the build limit ends above the maximum height") + void solidColumn() { + MemoryExtent extent = terrain.empty() + .fill(BlockVector3.at(0, Terrain.MIN_Y, 0), BlockVector3.at(0, Terrain.MAX_Y, 0), terrain.stone); + + assertThat(Height.getNearestNonEmptyBlock(extent, BlockVector3.at(0, 100, 0))).isEqualTo(Terrain.MAX_Y + 1); + } + + @Test + @DisplayName("flat ground has no height difference") + void flatGroundHasNoAngle() { + MemoryExtent extent = terrain.flat(); + + assertThat(Height.getAverageHeightDiffAngle(extent, Terrain.surface(), 1)).isZero(); + assertThat(Height.getAverageHeightDiffAngle(extent, Terrain.surface(), 3)).isZero(); + assertThat(Height.getAverageHeightDiffFracture(extent, Terrain.surface(), Terrain.SURFACE + 1, 2)).isZero(); + } + + @Test + @DisplayName("a four block step has a height difference angle of 2 on its edge") + void stepAngle() { + MemoryExtent extent = terrain.step(); + + assertThat(Height.getAverageHeightDiffAngle(extent, Terrain.surface(), 1)).isCloseTo(2.0, within(1e-9)); + assertThat(Height.getAverageHeightDiffAngle(extent, BlockVector3.at(-5, Terrain.SURFACE, 0), 1)).isZero(); + } + + @Test + @DisplayName("the bottom of a trench is lower than the ground around it") + void trenchFracture() { + MemoryExtent extent = terrain.trench(); + BlockVector3 bottom = BlockVector3.at(0, Terrain.SURFACE - 3, 0); + int height = Height.getNearestNonEmptyBlock(extent, bottom); + + assertThat(height).isEqualTo(Terrain.SURFACE - 2); + assertThat(Height.getAverageHeightDiffFracture(extent, bottom, height, 1)).isCloseTo(2.25, within(1e-9)); + assertThat(Height.getAverageHeightDiffFracture(extent, BlockVector3.at(5, Terrain.SURFACE, 0), Terrain.SURFACE + 1, 1)) + .isZero(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/utils/SphereTest.java b/src/test/java/net/onelitefeather/bettergopaint/utils/SphereTest.java new file mode 100644 index 00000000..6af5d393 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/utils/SphereTest.java @@ -0,0 +1,127 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils; + +import com.sk89q.worldedit.math.BlockVector3; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.bukkit.Axis; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class SphereTest { + + private final Terrain terrain = new Terrain(); + private final BlockVector3 middle = Terrain.surface(); + + private Set reference(MemoryExtent extent, int radius, boolean air) { + Set result = new HashSet<>(); + for (int x = -radius; x <= radius; x++) { + for (int y = -radius; y <= radius; y++) { + for (int z = -radius; z <= radius; z++) { + BlockVector3 position = middle.add(x, y, z); + boolean inside = Math.sqrt(x * x + y * y + z * z) < radius / 2d; + if (inside && (air || !extent.typeAt(position).equals(terrain.air))) { + result.add(position); + } + } + } + } + return result; + } + + @Test + @DisplayName("a sphere of size 6 holds every solid block closer than 3 blocks") + void solidBlocksInsideTheRadius() { + MemoryExtent extent = terrain.flat(); + + Set blocks = Sphere.getBlocksInRadius(extent, middle, 6, null, false).collect(Collectors.toSet()); + + assertThat(blocks).isEqualTo(reference(extent, 6, false)).isNotEmpty(); + assertThat(blocks).allMatch(position -> position.y() <= Terrain.SURFACE); + } + + @Test + @DisplayName("air blocks are only part of the sphere when asked for") + void airIsOptional() { + MemoryExtent extent = terrain.flat(); + + Set withAir = Sphere.getBlocksInRadius(extent, middle, 6, null, true).collect(Collectors.toSet()); + + assertThat(withAir).isEqualTo(reference(extent, 6, true)); + assertThat(withAir).anyMatch(position -> position.y() > Terrain.SURFACE); + } + + @Test + @DisplayName("a disc on the y axis stays on the height of the middle") + void yAxisDisc() { + Set blocks = Sphere.getBlocksInRadius(terrain.flat(), middle, 8, Axis.Y, false) + .collect(Collectors.toSet()); + + assertThat(blocks).isNotEmpty().allMatch(position -> position.y() == middle.y()); + assertThat(blocks).contains(middle, middle.add(3, 0, 0), middle.add(0, 0, -3)).doesNotContain(middle.add(4, 0, 0)); + } + + @Test + @DisplayName("a disc on the x axis stays on the x of the middle") + void xAxisDisc() { + Set blocks = Sphere.getBlocksInRadius(terrain.flat(), middle, 8, Axis.X, false) + .collect(Collectors.toSet()); + + assertThat(blocks).isNotEmpty().allMatch(position -> position.x() == middle.x()); + assertThat(blocks).contains(middle.add(0, -3, 0), middle.add(0, 0, 3)); + } + + @Test + @DisplayName("a disc on the z axis stays on the z of the middle") + void zAxisDisc() { + Set blocks = Sphere.getBlocksInRadius(terrain.flat(), middle, 8, Axis.Z, false) + .collect(Collectors.toSet()); + + assertThat(blocks).isNotEmpty().allMatch(position -> position.z() == middle.z()); + assertThat(blocks).contains(middle.add(3, 0, 0), middle.add(0, -3, 0)); + } + + @Test + @DisplayName("positions outside the build limits are never returned") + void respectsBuildLimits() { + MemoryExtent extent = terrain.empty(); + BlockVector3 bottom = BlockVector3.at(0, Terrain.MIN_Y, 0); + + Set blocks = Sphere.getBlocksInRadius(extent, bottom, 8, null, true).collect(Collectors.toSet()); + + assertThat(blocks).isNotEmpty().allMatch(position -> position.y() >= Terrain.MIN_Y); + } + + @Test + @DisplayName("the sphere reads from the extent it is given and does not change it") + void doesNotWrite() { + MemoryExtent extent = terrain.flat(); + + Sphere.getBlocksInRadius(extent, middle, 6, null, false).toList(); + + assertThat(extent.written()).isEmpty(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/utils/SurfaceTest.java b/src/test/java/net/onelitefeather/bettergopaint/utils/SurfaceTest.java new file mode 100644 index 00000000..4c16ec36 --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/utils/SurfaceTest.java @@ -0,0 +1,72 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils; + +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.math.Vector3; +import net.onelitefeather.bettergopaint.objects.other.SurfaceMode; +import net.onelitefeather.bettergopaint.support.MemoryExtent; +import net.onelitefeather.bettergopaint.support.Terrain; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SurfaceTest { + + private final Terrain terrain = new Terrain(); + private final MemoryExtent extent = terrain.flat(); + private final Vector3 eye = Vector3.at(0.5, Terrain.SURFACE + 3.5, 0.5); + + @Test + @DisplayName("direct mode accepts a solid block with a free block above") + void directTopBlock() { + assertThat(Surface.isDirectlyOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE, 0))).isTrue(); + } + + @Test + @DisplayName("direct mode rejects a buried block and air") + void directRejects() { + assertThat(Surface.isDirectlyOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE - 1, 0))).isFalse(); + assertThat(Surface.isDirectlyOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE + 1, 0))).isFalse(); + } + + @Test + @DisplayName("relative mode accepts a block the eye can see") + void relativeVisible() { + assertThat(Surface.isRelativelyOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE, 0), eye)).isTrue(); + } + + @Test + @DisplayName("relative mode rejects a block hidden below the ground") + void relativeHidden() { + assertThat(Surface.isRelativelyOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE - 3, 0), eye)).isFalse(); + } + + @Test + @DisplayName("the surface mode picks the check, and disabled accepts everything") + void modeSelectsCheck() { + BlockVector3 buried = BlockVector3.at(2, Terrain.SURFACE - 3, 0); + + assertThat(Surface.isOnSurface(extent, buried, SurfaceMode.DISABLED, eye)).isTrue(); + assertThat(Surface.isOnSurface(extent, buried, SurfaceMode.DIRECT, eye)).isFalse(); + assertThat(Surface.isOnSurface(extent, buried, SurfaceMode.RELATIVE, eye)).isFalse(); + assertThat(Surface.isOnSurface(extent, BlockVector3.at(2, Terrain.SURFACE, 0), SurfaceMode.DIRECT, eye)).isTrue(); + } +} diff --git a/src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java b/src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java new file mode 100644 index 00000000..7de86b3b --- /dev/null +++ b/src/test/java/net/onelitefeather/bettergopaint/utils/curve/BezierSplineTest.java @@ -0,0 +1,96 @@ +/* + * goPaint is designed to simplify painting inside of Minecraft. + * Copyright (C) Arcaniax-Development + * Copyright (C) Arcaniax team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.bettergopaint.utils.curve; + +import com.sk89q.worldedit.math.Vector3; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +class BezierSplineTest { + + private BezierSpline spline(Vector3... knots) { + return new BezierSpline(new LinkedList<>(List.of(knots))); + } + + @Test + @DisplayName("a spline through two knots is the straight line between them") + void twoKnotsFormAStraightLine() { + BezierSpline spline = spline(Vector3.at(0, 64, 0), Vector3.at(10, 64, 0)); + + assertThat(spline.getSegmentsCount()).isEqualTo(1); + assertThat(spline.getCurveLength()).isCloseTo(10, within(1.0)); + assertThat(spline.getPoint(0.5).x()).isCloseTo(5, within(0.01)); + } + + @Test + @DisplayName("the curve starts on the first knot and ends on the last") + void curvePassesThroughEndKnots() { + BezierSpline spline = spline( + Vector3.at(0, 64, 0), + Vector3.at(5, 64, 8), + Vector3.at(12, 64, 3) + ); + + Vector3 start = spline.getPoint(0); + Vector3 end = spline.getPoint(spline.getSegmentsCount()); + + assertThat(start.x()).isCloseTo(0, within(1e-9)); + assertThat(start.z()).isCloseTo(0, within(1e-9)); + assertThat(end.x()).isCloseTo(12, within(1e-9)); + assertThat(end.z()).isCloseTo(3, within(1e-9)); + } + + @Test + @DisplayName("knots on one height keep the whole curve on that height") + void flatKnotsStayFlat() { + BezierSpline spline = spline( + Vector3.at(0, 70, 0), + Vector3.at(4, 70, 9), + Vector3.at(9, 70, -2) + ); + + for (double t = 0; t <= spline.getSegmentsCount(); t += 0.25) { + assertThat(spline.getPoint(t).y()).as("y at t=%s", t).isEqualTo(70); + } + } + + @Test + @DisplayName("getT maps walked distance back onto the curve parameter") + void getTIsMonotonic() { + BezierSpline spline = spline( + Vector3.at(0, 64, 0), + Vector3.at(6, 64, 6), + Vector3.at(12, 64, 0) + ); + + double previous = -1; + for (double blocks = 0; blocks < spline.getCurveLength(); blocks += 1) { + double t = spline.getT(blocks); + assertThat(t).as("t after %s blocks", blocks).isGreaterThan(previous); + previous = t; + } + assertThat(spline.getT(spline.getCurveLength())).isEqualTo(spline.getSegmentsCount()); + } +}