diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 12f335f..3952713 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,32 +21,33 @@ concurrency: jobs: build: - name: Build + test on JDK 21 + name: Build + test on JDK 25 runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - - name: Set up JDK 21 - uses: actions/setup-java@v4 + - name: Set up JDK 25 + uses: actions/setup-java@v6.2.0 with: - java-version: '21' + java-version: '25' distribution: 'temurin' - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6.4.1 with: - gradle-home-cache-cleanup: true + cache-cleanup: 'always' - name: Build + tests (all modules) + shadowJar run: ./gradlew --no-daemon build :plugin:shadowJar - name: Publish test report if: always() - uses: mikepenz/action-junit-report@v5 + uses: mikepenz/action-junit-report@v6.7.1 with: report_paths: '**/build/test-results/test/TEST-*.xml' fail_on_failure: true + annotate_only: true require_tests: false - name: Resolve plugin JAR path @@ -62,7 +63,7 @@ jobs: echo "javadoc=$(ls api/build/libs/AbstractMenus-api-*-javadoc.jar | head -n1)" >> "$GITHUB_OUTPUT" - name: Upload plugin shadow JAR - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.2 with: name: AbstractMenus-jar path: ${{ steps.plugin_jar.outputs.path }} @@ -70,7 +71,7 @@ jobs: retention-days: 14 - name: Upload api artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.2 with: name: AbstractMenus-api path: | diff --git a/.github/workflows/publish-api.yml b/.github/workflows/publish-api.yml index e3113f2..5e7b386 100644 --- a/.github/workflows/publish-api.yml +++ b/.github/workflows/publish-api.yml @@ -20,18 +20,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout (release tag) - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 with: ref: ${{ github.event.release.tag_name || inputs.tag }} - - name: Set up JDK 21 - uses: actions/setup-java@v4 + - name: Set up JDK 25 + uses: actions/setup-java@v6.2.0 with: - java-version: '21' + java-version: '25' distribution: 'temurin' - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6.4.1 - name: Publish :api run: ./gradlew --no-daemon :api:publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b77bde6..922b3a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,19 +19,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout (release tag) - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 with: ref: ${{ github.event.release.tag_name || inputs.tag }} fetch-depth: 0 - - name: Set up JDK 21 - uses: actions/setup-java@v4 + - name: Set up JDK 25 + uses: actions/setup-java@v6.2.0 with: - java-version: '21' + java-version: '25' distribution: 'temurin' - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6.4.1 - name: Verify tag matches project version run: | @@ -46,7 +46,7 @@ jobs: exit 1 fi - - name: Build shadow plugin + api artifacts (skip tests โ€” already run on the tagged commit by build.yml) + - name: Build shadow plugin + api artifacts (skip tests - already run on the tagged commit by build.yml) run: ./gradlew --no-daemon :plugin:shadowJar :api:build - name: Resolve artifact paths @@ -63,7 +63,7 @@ jobs: echo "api_doc=$API_DOC" >> "$GITHUB_OUTPUT" - name: Attach JARs to release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3.0.4 with: tag_name: ${{ github.event.release.tag_name || inputs.tag }} files: | diff --git a/CLAUDE.md b/CLAUDE.md index ae430f8..1c0302f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,17 +4,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -AbstractMenus is a GUI plugin for SpigotMC/PaperMC Minecraft servers (1.20.6+) that allows creating custom inventory-based menus. Written in Java 21, it uses Gradle with PaperMC Paperweight userdev and Shadow plugin. +AbstractMenus is a GUI plugin for PaperMC/Folia Minecraft servers (26.1+) that allows creating custom inventory-based menus. Written in Java 25, it uses Gradle 9.9.1 with the Paper 26.3 API and Shadow plugin. ## Build Commands ```bash ./gradlew build # Compile and run tests -./gradlew shadowJar # Create fat JAR with bundled dependencies (output: build/libs/AbstractMenus-.jar) -./gradlew test # Run JUnit 5 tests only +./gradlew shadowJar # Create fat JAR with bundled dependencies (output: build/dist/AbstractMenus-.jar) +./gradlew test # Run JUnit 6 tests only ``` -Tests are in `src/test/java/` using JUnit 5 (Jupiter). There are currently 4 test classes. Run a single test with: +Tests are in `plugin/src/test/java/` using JUnit 6 (Jupiter), Mockito, and MockBukkit 26.3. Run a single test with: ```bash ./gradlew test --tests "ru.abstractmenus.TestValueComparator" ``` @@ -65,11 +65,11 @@ Menu (API interface) Menus and config use HOCON format (not YAML). The internal HOCON library is at `ru.abstractmenus.hocon`. ### Key Dependencies -- **API module**: `com.github.AbstractMenus:api` (separate repo, pulled via JitPack) +- **API module**: Local `:api` subproject (`ru.abstractmenus:api`) - **Lombok**: Used throughout for `@Getter`/`@Setter`/`@AllArgsConstructor` - **Kyori Adventure + MiniMessage**: Rich text component API - **FoliaLib**: Thread-safe task scheduling (Folia compatibility) -- **Paperweight userdev**: NMS access with Mojang mappings +- **Paper API**: Compile-only Paper 26.3 API dependency ### Adding a New Action/Rule/Property/Activator 1. Create class in the appropriate `data/` subpackage diff --git a/README.md b/README.md index 9efef4b..9aede0a 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ license version -minecraft -paper -java +minecraft +paper +java folia AbstractMenus is a GUI plugin for Paper / Folia Minecraft servers that lets @@ -13,9 +13,7 @@ HOCON configuration. Menus, rules, actions, activators, and item properties are all first-class, extensible via an open type registry, and render through a cached, MiniMessage-aware pipeline. -The plugin supports **Minecraft 1.20.6 onwards**. Older versions were dropped -to keep the codebase free of backwards-compatibility wrappers and to track the -current Paper API surface. +The plugin supports **Minecraft 26.1 onwards** and requires **Java 25**. The build targets the Paper 26.3 API. ## ๐Ÿ”ญ Table of contents - [Features](#-features) @@ -51,6 +49,8 @@ current Paper API surface. BungeeCord channel. - **Folia-compatible scheduling** โ€” all Bukkit scheduler calls go through a `BukkitTasks` wrapper backed by FoliaLib. + + **Known limitations:** The `playerScope` rule cannot safely read another player's state across Folia regions. - **External integrations (soft-deps)** โ€” Vault, LuckPerms, WorldGuard, SkinsRestorer, MMOItems, HeadDatabase, ItemsAdder, Oraxen, Citizens, NBT-API. @@ -62,47 +62,94 @@ current Paper API surface. ## ๐Ÿงฐ Item properties -Item properties are configured per menu item. The `tooltipDisplay` property -wraps the Paper `tooltip_display` data component (Minecraft 1.21.5+). +Item properties are configured per menu item on Minecraft 26.x. Namespaced keys containing `:` must be quoted in HOCON; keys without a namespace use `minecraft`. -Hide an item's tooltip entirely: +`tooltipDisplay` can hide an entire tooltip or selected data components, alongside normal name and lore properties: ```hocon -material: DIAMOND_SWORD +name: "Bundle" tooltipDisplay { - hideTooltip: true + hideTooltip: false + hiddenComponents: [BUNDLE_CONTENTS, "minecraft:enchantments"] } +lore: ["Contents stay hidden"] ``` -Hide the tooltip of specific components, e.g. only the Bundle contents: +Set `hideTooltip: true` to hide the whole tooltip. Component names accept uppercase, lowercase and namespaced keys. Unknown components fail at menu load. Hidden components are merged with existing flags, so `tooltipDisplay` and `flags` can appear in either order. + +`itemModel` selects a resource-pack item model; `tooltipStyle` selects tooltip sprites and requires a resource pack that defines the style: ```hocon -material: BUNDLE -tooltipDisplay { - hiddenComponents: [ - BUNDLE_CONTENTS - ] -} +itemModel: "example:menu/cake" +tooltipStyle: "example:menu/gold" ``` -Several components at once: +`maxStackSize` accepts integers from 1 through 99, with invalid constants rejected at menu load. It also accepts integer placeholders; results outside that range log a warning and leave the existing stack limit unchanged. Stack sizes above one cannot be combined with the `max_damage` component, so use them on items without durability: ```hocon -tooltipDisplay { - hiddenComponents: [ - BUNDLE_CONTENTS, - ATTRIBUTE_MODIFIERS, - ENCHANTMENTS - ] +material: CAKE +maxStackSize: 99 +count: 64 +``` + +`rarity` accepts `COMMON`, `UNCOMMON`, `RARE` or `EPIC`, including enum placeholders: + +```hocon +rarity: RARE +``` + +`model` keeps the integer format used by existing menus, mapping it to one float in the custom model data component: + +```hocon +model: 123 +``` + +It also accepts an object containing any of the four custom model data lists. Floats, flags, strings and colors support placeholders; colors accept hex, RGB strings or dye names: + +```hocon +model { + floats: [1.0, 2.5] + flags: [true, false] + strings: ["cake"] + colors: ["#ff8000"] } ``` -`hiddenComponents` accepts component keys case-insensitively and in -namespaced form (`BUNDLE_CONTENTS`, `bundle_contents`, -`"minecraft:bundle_contents"`). Unknown component names fail loudly at menu load. -Namespaced keys contain `:`, which HOCON only allows inside quotes, so write -them as `"minecraft:bundle_contents"`. Namespaced keys without an explicit -namespace are assumed to be `minecraft:`. +`glow: true` forces the enchantment glint without adding an enchantment; `false` leaves the item's glint unchanged: + +```hocon +glow: true +``` + +`flags` hides the matching tooltip components and preserves attribute modifiers, including those configured with `attributeModifier`: + +```hocon +material: DIAMOND_SWORD +flags: HIDE_ATTRIBUTES +``` + +`count` is passed through unchanged, including amounts above the item's maximum stack size: + +```hocon +material: DIAMOND_SWORD +count: 64 +``` + +`data` remains accepted as a byte-valued alias for item damage through `Damageable`. It no longer reads or writes legacy material data; use `damage` for new menus. + +## Upgrading + +This version supports Minecraft 26.1+ and Java 25 only. + +`glow` no longer adds an Unbreaking enchantment, and `flags` no longer writes default attribute modifiers. Items handed to players by older versions, such as menu opener items and quest tokens, that carry those old enchantments or modifiers no longer match updated item definitions in `clickItem` activators, `heldItem` and `inventoryItem` rules, or `itemRemove`. Reissue those affected items after updating. + +The built-in placeholder values have changed: + +- `block_data` returns the full block state string, such as `minecraft:oak_log[axis=y]`, instead of a legacy numeric data byte. +- `item_data` returns the item's damage value, or `0` for items without damage. +- `block_biome` returns the registry key path in uppercase, such as `PLAINS`. + +`count` is passed through unchanged. ## ๐Ÿ”— Links - [SpigotMC](https://www.spigotmc.org/resources/abstract-menus-an-advanced-gui-plugin.75107/) @@ -110,9 +157,9 @@ namespace are assumed to be `minecraft:`. - [Documentation](https://abstractmenus.github.io/docs/index.html) ## ๐Ÿงฐ Requirements -- **JDK 21+** (target and source) -- **Gradle 9.x** (wrapper included) -- **Paper** or Folia, **1.20.6 โ€“ 1.21.11+** (built against 1.21.11 dev bundle) +- **JDK 25+** (target and source) +- **Gradle 9.9.1** (wrapper included) +- **Paper** or Folia, **26.1+** (built against the Paper 26.3 API) ## ๐Ÿ”จ Build Clone and build a distributable shaded JAR: @@ -131,7 +178,7 @@ Other useful tasks: ```bash ./gradlew build # compile + tests -./gradlew test # JUnit 5 + Mockito tests +./gradlew test # JUnit 6 + Mockito tests ./gradlew test --tests "ru.abstractmenus.util.TestStringUtil" ./gradlew jmh -Pbench=PlaceholderReplace # JMH micro-benchmarks ``` @@ -199,23 +246,16 @@ build, and concurrent map access. ``` ## ๐Ÿงช Tests -~190 unit and integration tests under `src/test/java/`, covering pure-logic -utilities, the HOCON type deserializers, the copy-on-write item contract, the -MiniMessage legacy color path, and Mockito-based regression pinning for -Bukkit API fixes. +Unit and integration tests under `plugin/src/test/java/`, covering pure-logic utilities, the HOCON type deserializers, the copy-on-write item contract, the MiniMessage legacy color path, and Mockito-based regression pinning for Bukkit API fixes. ```bash ./gradlew test ``` -MockBukkit-backed integration tests are scaffolded but currently `@Disabled` -due to an upstream MockBukkit โ†” Paper 1.21.11 registry-bootstrap collision. +MockBukkit 26.3 integration tests verify that the plugin enables, initializes its menu manager, and disables cleanly. ## ๐Ÿ‘ช Contributions and feedback -If you have suggestions for improvement or want to report a bug, feel free -to create an issue or a pull request in the project repository. The -codebase follows standard Java style, uses Lombok for boilerplate, and -targets Java 21 (pattern matching, records, switch expressions welcome). +If you have suggestions for improvement or want to report a bug, feel free to create an issue or a pull request in the project repository. The codebase follows standard Java style, uses Lombok for boilerplate, and targets Java 25 (pattern matching, records, switch expressions welcome). ## ๐Ÿ™Œ Contributors diff --git a/about.md b/about.md index dc53d04..850eea3 100644 --- a/about.md +++ b/about.md @@ -2,12 +2,12 @@ ## Project Summary -AbstractMenus is a GUI plugin for SpigotMC/PaperMC/Folia Minecraft servers that allows creating custom inventory-based menus configured via HOCON files. Players interact with these menus through various activators (commands, items, events), and server admins define menu layouts, rules, and actions declaratively. +AbstractMenus is a GUI plugin for PaperMC/Folia Minecraft servers that allows creating custom inventory-based menus configured via HOCON files. Players interact with these menus through various activators (commands, items, events), and server admins define menu layouts, rules, and actions declaratively. -- **Target MC version:** 1.21.11 -- **Minimum MC version:** 1.20.6 -- **Java version:** 21 -- **Build system:** Gradle 8.8 with Paperweight userdev + Shadow plugin +- **Target MC version:** 26.3 +- **Minimum MC version:** 26.1 +- **Java version:** 25 +- **Build system:** Gradle 9.9.1 with Paper API + Shadow plugin - **Configuration format:** HOCON (bundled library at `ru.abstractmenus.hocon`) ## Architecture Layers @@ -22,7 +22,7 @@ Services Layer (MenuManager, BungeeManager, HeadAnimManager, ProfileStorage, Var Data Layer (Actions, Rules, Activators, ItemProps, Catalogs - ~164 files) | v -API Module (com.github.AbstractMenus:api - external, JitPack) +API Module (:api - local Gradle subproject) ``` ## Plugin Lifecycle @@ -97,17 +97,18 @@ Abstracted through interfaces in `handlers/` package, accessed via static `Handl ## NMS Layer -Version-specific code for features not available in Bukkit API: -- **Book:** `Book_1_15` uses `player.openBook()` (modern API). Legacy versions (1.8-1.14) use NMS reflection - **to be removed** (min version 1.20.6+). -- **Title:** `SenderModern` uses `player.sendTitle()`. `SenderLegacy` uses NMS packets - **to be removed**. -- **ActionBar:** `ActionBar_1_9` uses Spigot `sendMessage(ChatMessageType.ACTION_BAR)`. `ActionBar_1_8` uses NMS - **to be removed**. +Wrappers use the current server APIs without Minecraft version checks. + +- **Book:** `Book_1_15` uses `player.openBook()`. +- **Title:** `SenderModern` uses `player.sendTitle()`. +- **ActionBar:** `ActionBar_1_9` uses Spigot `sendMessage(ChatMessageType.ACTION_BAR)`. ## Key Dependencies | Dependency | Role | |---|---| -| `paper-api` / `paperDevBundle` | Server API + NMS access via Mojang mappings | -| `AbstractMenus:api` | Core API interfaces (Menu, Item, Rule, Action, etc.) | +| `paper-api` | Compile-only Paper 26.3 server API | +| `:api` | Core API interfaces (Menu, Item, Rule, Action, etc.) | | `Lombok` | Boilerplate reduction (@Getter, @Setter, @AllArgsConstructor) | | `Kyori Adventure + MiniMessage` | Rich text component API, MiniMessage format support | | `FoliaLib` | Thread-safe task scheduling (Folia compatibility) | @@ -121,16 +122,13 @@ Version-specific code for features not available in Bukkit API: - `VariablesDao.connection`: Single SQLite `Connection` used from multiple threads without synchronization - `AbstractMenu.placedItems`: Lazy init `if (null) new HashMap<>()` is not thread-safe - `BungeeManager.servers`: `HashSet` accessed from message handler and scheduler threads -- Static singletons (`NMS.minorVersion`, `Title.sender`, `ActionBar.bar`) lack `volatile` keyword ### Memory Leaks - `HeadAnimManager.currentFrames`: ConcurrentHashMap entries accumulate indefinitely - `AbstractMenu.placedItems`: Not cleared after `dropPlaced()` ### Deprecated/Legacy Code -- NMS reflection code for MC versions < 1.20.6 (6 files to remove) - `Skulls.createSkullItem()`: Uses deprecated `ItemStack(Material, int, short)` constructor -- `NMS.sendPacket()`: Legacy reflection-based packet sending ### Removed: RuleJS (Nashorn) The `js` rule type (JavaScript evaluation via Nashorn engine) has been **removed**. It was a security risk (arbitrary code execution) and Nashorn was removed from JDK in Java 15+. Use the `if` rule with `BooleanEvaluator` as an alternative for conditional logic. diff --git a/api/build.gradle b/api/build.gradle index 43bc7df..e7aa961 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -20,14 +20,14 @@ repositories { } dependencies { - compileOnly 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' + compileOnly 'io.papermc.paper:paper-api:26.3.build.+' api 'com.github.AbstractMenus:hocon:1.0.6' - compileOnly 'org.projectlombok:lombok:1.18.44' - annotationProcessor 'org.projectlombok:lombok:1.18.44' + compileOnly 'org.projectlombok:lombok:1.18.48' + annotationProcessor 'org.projectlombok:lombok:1.18.48' - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.14.3' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.14.3' + testImplementation 'org.junit.jupiter:junit-jupiter-api:6.1.3' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:6.1.3' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/build.gradle b/build.gradle index 2417ef7..d07085c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ // Common configuration applied to every subproject. -// Subproject-specific plugins (java-library, shadow, paperweight) stay in subprojects. +// Subproject-specific plugins (java-library, shadow) stay in subprojects. plugins { // Root needs the base lifecycle ('build', 'assemble', 'clean') so we @@ -14,8 +14,8 @@ allprojects { repositories { mavenLocal() mavenCentral() - maven { url 'https://repo.papermc.io/repository/maven-public/' } - maven { url 'https://jitpack.io' } + maven { url = 'https://repo.papermc.io/repository/maven-public/' } + maven { url = 'https://jitpack.io' } } } @@ -23,10 +23,10 @@ subprojects { apply plugin: 'java' java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e644113..51cbd2a 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2dcec85..bdc1ff0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,10 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-bin.zip +distributionSha256Sum=d8a18a7a841dd5df825d35a685fad6f2d7f12ac0c280e57b67c27b4a520ba228 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.9.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index b740cf1..249efbb 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 25da30d..3185a43 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/jitpack.yml b/jitpack.yml index 8e1a95a..c9f8338 100644 --- a/jitpack.yml +++ b/jitpack.yml @@ -1,8 +1,8 @@ jdk: - - openjdk21 + - openjdk25 before_install: - - sdk install java 21.0.3-tem < /dev/null || true + - sdk install java 25.0.5.0+8-tem < /dev/null || true install: - ./gradlew :api:publishToMavenLocal -x test diff --git a/plugin/build.gradle b/plugin/build.gradle index ef92f88..7bbe31e 100644 --- a/plugin/build.gradle +++ b/plugin/build.gradle @@ -1,50 +1,42 @@ -import proguard.gradle.ProGuardTask - -buildscript { - repositories { - mavenCentral() - mavenLocal() - } - dependencies { - classpath 'com.guardsquare:proguard-gradle:7.9.1' - } -} - plugins { - id 'com.gradleup.shadow' version '9.4.1' - id 'io.papermc.paperweight.userdev' version '2.0.0-beta.21' + id 'com.gradleup.shadow' version '9.6.1' } // Plugin-specific repos not already in root repositories { - maven { url 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } - maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } - maven { url 'https://maven.enginehub.org/repo/' } - maven { url 'https://repo.extendedclip.com/content/repositories/placeholderapi/' } - maven { url 'https://mvn.lumine.io/repository/maven-public/' } + exclusiveContent { + forRepository { maven { url = 'https://maven.citizensnpcs.co/repo' } } + filter { includeGroup 'net.citizensnpcs' } + } + maven { url = 'https://repo.tcoded.com/releases' } + maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } + maven { url = 'https://oss.sonatype.org/content/repositories/snapshots' } + maven { url = 'https://maven.enginehub.org/repo/' } + maven { url = 'https://repo.extendedclip.com/content/repositories/placeholderapi/' } + maven { url = 'https://mvn.lumine.io/repository/maven-public/' } maven { - url 'https://repo.codemc.org/repository/maven-public/' + url = 'https://repo.codemc.org/repository/maven-public/' metadataSources { artifact() } } maven { - url 'https://repo.codemc.org/repository/maven-snapshots/' + url = 'https://repo.codemc.org/repository/maven-snapshots/' metadataSources { artifact() } } maven { - url 'https://repo.codemc.org/repository/maven-releases/' + url = 'https://repo.codemc.org/repository/maven-releases/' metadataSources { artifact() } } } dependencies { - compileOnly 'org.projectlombok:lombok:1.18.44' - annotationProcessor 'org.projectlombok:lombok:1.18.44' + compileOnly 'org.projectlombok:lombok:1.18.48' + annotationProcessor 'org.projectlombok:lombok:1.18.48' - paperweight.paperDevBundle("1.21.11-R0.1-SNAPSHOT") + compileOnly 'io.papermc.paper:paper-api:26.3.build.+' implementation project(':api') - implementation 'com.fathzer:javaluator:3.0.6' + implementation 'com.fathzer:javaluator:3.0.9' // Adventure (Component, MiniMessage, LegacyComponentSerializer) is // bundled INSIDE Paper itself (via paper-api's BOM). Pull it in @@ -52,14 +44,14 @@ dependencies { // ~2.7 MB of duplicate Adventure classes into the shadow jar. // adventure-platform-bukkit was used by nothing in our code so it is // dropped entirely. - compileOnly 'net.kyori:adventure-text-minimessage:4.26.1' + compileOnly 'net.kyori:adventure-text-minimessage:5.2.0' - implementation "com.github.technicallycoded:FoliaLib:0.4.4" + implementation "com.tcoded:FoliaLib:0.5.2" shadow files('libs/MMOItems-6.5.4.jar') - shadow files('libs/citizens-2.0.jar') + shadow('net.citizensnpcs:citizensapi:2.0.44-SNAPSHOT') { transitive = false } - shadow ('de.tr7zw:item-nbt-api-plugin:2.15.7') { + shadow ('de.tr7zw:item-nbt-api-plugin:2.16.1') { exclude group: 'org.bstats', module: 'bstats-bukkit' } shadow ('com.arcaniax:HeadDatabase-API:1.3.2') { @@ -72,13 +64,13 @@ dependencies { shadow ('net.luckperms:api:5.5') { exclude group: 'org.bstats', module: 'bstats-bukkit' } - shadow ('com.sk89q.worldguard:worldguard-bukkit:7.0.0') { + shadow ('com.sk89q.worldguard:worldguard-bukkit:7.0.21') { exclude group: 'org.bstats', module: 'bstats-bukkit' exclude group: 'org.bukkit' exclude group: 'io.papermc.paper' } - compileOnly 'me.clip:placeholderapi:2.12.2' + compileOnly 'me.clip:placeholderapi:2.12.3' shadow ('io.lumine:MythicLib:1.0.12-SNAPSHOT') { exclude group: 'org.bstats', module: 'bstats-bukkit' @@ -86,20 +78,24 @@ dependencies { shadow ('com.github.LoneDev6:api-itemsadder:3.6.1') { exclude group: 'org.bstats', module: 'bstats-bukkit' } - shadow ('net.skinsrestorer:skinsrestorer-api:15.10.0') { + shadow ('net.skinsrestorer:skinsrestorer-api:15.12.6') { exclude group: 'org.bstats', module: 'bstats-bukkit' } - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.14.3' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.14.3' + testImplementation('net.citizensnpcs:citizensapi:2.0.44-SNAPSHOT') { transitive = false } + testImplementation 'io.papermc.paper:paper-api:26.3.build.+' + + testImplementation 'org.junit.jupiter:junit-jupiter-api:6.1.3' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:6.1.3' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testImplementation 'org.mockbukkit.mockbukkit:mockbukkit-v1.21:4.108.0' - testImplementation 'org.mockito:mockito-core:5.15.2' - testImplementation 'org.mockito:mockito-junit-jupiter:5.15.2' + testRuntimeOnly 'org.xerial:sqlite-jdbc:3.54.1.0' + testImplementation 'org.mockbukkit.mockbukkit:mockbukkit-v26.3:4.120.0' + testImplementation 'org.mockito:mockito-core:5.24.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.24.0' testImplementation 'org.openjdk.jmh:jmh-core:1.37' testAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37' - testCompileOnly 'org.projectlombok:lombok:1.18.44' - testAnnotationProcessor 'org.projectlombok:lombok:1.18.44' + testCompileOnly 'org.projectlombok:lombok:1.18.48' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.48' } // The :plugin module ships as a fat jar named AbstractMenus-.jar. diff --git a/plugin/libs/citizens-2.0.jar b/plugin/libs/citizens-2.0.jar deleted file mode 100644 index f4e65f6..0000000 Binary files a/plugin/libs/citizens-2.0.jar and /dev/null differ diff --git a/plugin/libs/paper-1.20.6-147.jar b/plugin/libs/paper-1.20.6-147.jar deleted file mode 100644 index 81237d0..0000000 Binary files a/plugin/libs/paper-1.20.6-147.jar and /dev/null differ diff --git a/plugin/proguard.txt b/plugin/proguard.txt deleted file mode 100644 index 696f8be..0000000 --- a/plugin/proguard.txt +++ /dev/null @@ -1,62 +0,0 @@ -# Include java runtime classes - --libraryjars /lib/rt.jar -#-libraryjars /jmods - --dontwarn -#-dontoptimize - --optimizations !method/inlining/unique --optimizations !class/merging/* - --keep public class ru.abstractmenus.AbstractMenus --keep class ru.abstractmenus.Metrics{*;} --keep class ru.abstractmenus.Metrics$*{*;} --keep class ru.abstractmenus.api.**{*;} --keep class ru.abstractmenus.hocon.api.**{*;} - --keepclassmembers public class ru.abstractmenus.AbstractMenus{ - private ; -} - --keepclassmembernames public class ru.abstractmenus.AbstractMenus{ - private ; -} - --keepclassmembers public class ru.abstractmenus.events.RegionEvent{ - public static org.bukkit.event.HandlerList getHandlerList(); -} - --keepclassmembers class ** { - @org.bukkit.event.EventHandler - ; - @org.bukkit.event.EventHandler - ; -} - --keepattributes *Annotation* --keepattributes EventHandler --keepattributes LineNumberTable, Signature, Exceptions, InnerClasses, EnclosingMethod - --keepclassmembernames class * { - java.lang.Class class$(java.lang.String); - java.lang.Class class$(java.lang.String, boolean); -} - --keepclasseswithmembernames class * { - native ; -} - --keepclassmembers enum * { - public static **[] values(); - public static ** valueOf(java.lang.String); -} - --keepclassmembers class * implements java.io.Serializable { - static final long serialVersionUID; - private static final java.io.ObjectStreamField[] serialPersistentFields; - private void writeObject(java.io.ObjectOutputStream); - private void readObject(java.io.ObjectInputStream); - java.lang.Object writeReplace(); - java.lang.Object readResolve(); -} \ No newline at end of file diff --git a/plugin/src/main/java/ru/abstractmenus/AbstractMenus.java b/plugin/src/main/java/ru/abstractmenus/AbstractMenus.java index 4af53f8..001d057 100644 --- a/plugin/src/main/java/ru/abstractmenus/AbstractMenus.java +++ b/plugin/src/main/java/ru/abstractmenus/AbstractMenus.java @@ -4,7 +4,6 @@ import lombok.Getter; import lombok.Setter; import org.bukkit.Bukkit; -import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.ServicePriority; @@ -48,17 +47,13 @@ import ru.abstractmenus.util.TimeUtil; import ru.abstractmenus.util.bukkit.BukkitTasks; import ru.abstractmenus.util.bukkit.Events; -import ru.abstractmenus.util.proxy.ClassInfo; import ru.abstractmenus.variables.VariableManagerImpl; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Objects; import java.util.Optional; @Getter -public final class AbstractMenus extends JavaPlugin { +public class AbstractMenus extends JavaPlugin { private static AbstractMenus instance; @@ -103,8 +98,8 @@ public void onLoad() { public void onEnable() { try { instance = this; - isProxyMode = determineProxy(); Logger.set(getLogger()); + isProxyMode = determineProxy(); metrics = new Metrics(this); foliaLib = new FoliaLib(this); @@ -281,31 +276,8 @@ public static AbstractMenus instance() { return instance; } - // from SkinRestorer. - // - // YamlConfiguration.loadConfiguration below reads spigot.yml / paper.yml - // synchronously on the main thread. These files are <10 KB on a real - // server and the call only fires once, during onEnable, before any - // gameplay can be affected. Acceptable startup-only IO. public boolean determineProxy() { - Path spigotFile = Paths.get("spigot.yml"); - Path paperFile = Paths.get("paper.yml"); - - if (Optional.of(getConfig()).map(config -> - config.getBoolean("settings.bungeecord")).orElse(false)) { - return true; - } else if (ClassInfo.get().isSpigot() // Only consider files if classes for that platform are present - && Files.exists(spigotFile) - && YamlConfiguration.loadConfiguration(spigotFile.toFile()) - .getBoolean("settings.bungeecord")) { - return true; - } else if (Optional.of(Bukkit.spigot().getPaperConfig()).map(config -> - config.getBoolean("settings.velocity-support.enabled") - || config.getBoolean("proxies.velocity.enabled")).orElse(false)) { - return true; - } else return ClassInfo.get().isPaper() // Only consider files if classes for that platform are present - && Files.exists(paperFile) - && YamlConfiguration.loadConfiguration(paperFile.toFile()) - .getBoolean("settings.velocity-support.enabled"); + return getConfig().getBoolean("settings.bungeecord") + || getServer().getServerConfig().isProxyEnabled(); } } diff --git a/plugin/src/main/java/ru/abstractmenus/Metrics.java b/plugin/src/main/java/ru/abstractmenus/Metrics.java index 6abeae9..a04fd06 100644 --- a/plugin/src/main/java/ru/abstractmenus/Metrics.java +++ b/plugin/src/main/java/ru/abstractmenus/Metrics.java @@ -7,7 +7,6 @@ import lombok.Getter; import org.bukkit.Bukkit; import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.RegisteredServiceProvider; import org.bukkit.plugin.ServicePriority; @@ -221,17 +220,7 @@ public JsonObject getPluginData() { @SuppressWarnings("UnstableApiUsage") private JsonObject getServerData() { // Minecraft specific data - int playerAmount; - try { - // Around MC 1.8 the return type was changed to a collection from an array, - // This fixes java.lang.NoSuchMethodError: org.bukkit.Bukkit.getOnlinePlayers()Ljava/util/Collection; - Method onlinePlayersMethod = Class.forName("org.bukkit.Server").getMethod("getOnlinePlayers"); - playerAmount = onlinePlayersMethod.getReturnType().equals(Collection.class) - ? ((Collection) onlinePlayersMethod.invoke(Bukkit.getServer())).size() - : ((Player[]) onlinePlayersMethod.invoke(Bukkit.getServer())).length; - } catch (Exception e) { - playerAmount = Bukkit.getOnlinePlayers().size(); // Just use the new method if the Reflection failed - } + int playerAmount = Bukkit.getOnlinePlayers().size(); int onlineMode = Bukkit.getOnlineMode() ? 1 : 0; String bukkitVersion = Bukkit.getVersion(); String bukkitName = Bukkit.getName(); diff --git a/plugin/src/main/java/ru/abstractmenus/addon/AddonClassLoader.java b/plugin/src/main/java/ru/abstractmenus/addon/AddonClassLoader.java index f27b3ce..949f78a 100644 --- a/plugin/src/main/java/ru/abstractmenus/addon/AddonClassLoader.java +++ b/plugin/src/main/java/ru/abstractmenus/addon/AddonClassLoader.java @@ -20,6 +20,7 @@ public final class AddonClassLoader extends URLClassLoader { static final String[] PARENT_FIRST_PREFIXES = { "ru.abstractmenus.api.", + "ru.abstractmenus.hocon.", "org.bukkit.", "org.spigotmc.", "io.papermc.", diff --git a/plugin/src/main/java/ru/abstractmenus/addon/AddonManager.java b/plugin/src/main/java/ru/abstractmenus/addon/AddonManager.java index 572e45c..f7cd9f9 100644 --- a/plugin/src/main/java/ru/abstractmenus/addon/AddonManager.java +++ b/plugin/src/main/java/ru/abstractmenus/addon/AddonManager.java @@ -176,6 +176,16 @@ public void loadAll() { addons.put(k, la); continue; } + boolean dependenciesEnabled = la.getConf().addonDependencies().stream().allMatch(dep -> { + LoadedAddon required = addons.get(dep.toLowerCase()); + return required != null && required.getStatus() == AddonStatus.ENABLED; + }); + if (!dependenciesEnabled) { + la.markFailed(new IllegalStateException("Required addon dependency failed to enable")); + rollbackRegistrations(la); + addons.put(k, la); + continue; + } try { la.getExtension().onEnable(api); la.markEnabled(); @@ -434,17 +444,31 @@ public Optional loadOne(String name) { * have ended up in FAILED state). */ public List rescan() { - List newlyLoaded = new ArrayList<>(); Map discovered = discover(); - for (var entry : discovered.entrySet()) { - LoadedAddon la = entry.getValue(); + var iterator = discovered.entrySet().iterator(); + while (iterator.hasNext()) { + var entry = iterator.next(); if (addons.containsKey(entry.getKey())) { - // Already loaded - drop the redundant classloader we just built. - try { la.getClassLoader().close(); } catch (Exception ignored) {} - continue; + try { entry.getValue().getClassLoader().close(); } catch (Exception ignored) {} + iterator.remove(); + } + } + Map> graph = new LinkedHashMap<>(); + discovered.forEach((key, addon) -> graph.put(key, addon.getConf().addonDependencies().stream() + .map(String::toLowerCase).filter(discovered::containsKey).toList())); + List newlyLoaded = new ArrayList<>(); + try { + for (String key : AddonDependencyGraph.topoSort(graph)) { + LoadedAddon addon = discovered.get(key); + enableSingle(addon); + newlyLoaded.add(addon); + } + } catch (AddonDependencyCycleException ex) { + for (var entry : discovered.entrySet()) { + entry.getValue().markFailed(ex); + addons.put(entry.getKey(), entry.getValue()); + newlyLoaded.add(entry.getValue()); } - enableSingle(la); - newlyLoaded.add(la); } if (!newlyLoaded.isEmpty()) cachedAvailableAt = 0L; return newlyLoaded; diff --git a/plugin/src/main/java/ru/abstractmenus/command/CommandManager.java b/plugin/src/main/java/ru/abstractmenus/command/CommandManager.java index d454e51..5e72e89 100644 --- a/plugin/src/main/java/ru/abstractmenus/command/CommandManager.java +++ b/plugin/src/main/java/ru/abstractmenus/command/CommandManager.java @@ -2,11 +2,10 @@ import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; -import org.bukkit.command.SimpleCommandMap; +import org.bukkit.command.CommandMap; import org.bukkit.plugin.Plugin; import ru.abstractmenus.command.bukkit.CommandWrapper; -import java.lang.reflect.Method; import java.util.*; import java.util.stream.Collectors; @@ -14,23 +13,19 @@ public final class CommandManager { private final Plugin plugin; private final Map commands = new HashMap<>(); - private SimpleCommandMap commandMap; + private final CommandMap commandMap; public CommandManager(Plugin plugin) { this.plugin = plugin; - try { - String version = Bukkit.getServer().getClass().getPackage().getName().split("\\.")[3]; - Class craftServerClass = Class.forName(String.format("org.bukkit.craftbukkit.%s.CraftServer", version)); - Method getCommandMap = craftServerClass.getDeclaredMethod("getCommandMap"); - commandMap = (SimpleCommandMap) getCommandMap.invoke(Bukkit.getServer()); - } catch (Exception e) { - commandMap = (SimpleCommandMap) Bukkit.getCommandMap(); - } + commandMap = Bukkit.getCommandMap(); } public Command get(String name) { - return commands.get(name.toLowerCase()); + String key = name.toLowerCase(Locale.ROOT); + String prefix = plugin.getName().toLowerCase(Locale.ROOT) + ":"; + if (key.startsWith(prefix)) key = key.substring(prefix.length()); + return commands.get(key); } @SuppressWarnings("UnstableApiUsage") diff --git a/plugin/src/main/java/ru/abstractmenus/commands/varp/VarpSet.java b/plugin/src/main/java/ru/abstractmenus/commands/varp/VarpSet.java index f0b31f4..abaed93 100644 --- a/plugin/src/main/java/ru/abstractmenus/commands/varp/VarpSet.java +++ b/plugin/src/main/java/ru/abstractmenus/commands/varp/VarpSet.java @@ -18,7 +18,7 @@ public VarpSet() { @Override public void execute(CommandSender sender, String[] args) { - if (args.length >= 2) { + if (args.length >= 3) { String player = args[0]; String name = args[1]; String value = args[2]; @@ -58,8 +58,8 @@ public void execute(CommandSender sender, String[] args) { if (args.length == 5) { // set time replace var try { - long time = TimeUtil.parseTime(args[2]); - boolean replace = Boolean.parseBoolean(args[3]); + long time = TimeUtil.parseTime(args[3]); + boolean replace = Boolean.parseBoolean(args[4]); var = var.toBuilder() .expiry(System.currentTimeMillis() + time) .build(); diff --git a/plugin/src/main/java/ru/abstractmenus/core/CoreItemPropsBundle.java b/plugin/src/main/java/ru/abstractmenus/core/CoreItemPropsBundle.java index ce4d247..1a12f53 100644 --- a/plugin/src/main/java/ru/abstractmenus/core/CoreItemPropsBundle.java +++ b/plugin/src/main/java/ru/abstractmenus/core/CoreItemPropsBundle.java @@ -35,6 +35,10 @@ import ru.abstractmenus.data.properties.PropNameLight; import ru.abstractmenus.data.properties.PropLoreLight; import ru.abstractmenus.data.properties.PropTooltipDisplay; +import ru.abstractmenus.data.properties.PropItemModel; +import ru.abstractmenus.data.properties.PropTooltipStyle; +import ru.abstractmenus.data.properties.PropMaxStackSize; +import ru.abstractmenus.data.properties.PropRarity; /** * Core item property registrations. Mirrors {@code ItemProps.init()} before @@ -69,6 +73,10 @@ void register(AbstractMenusApi api, MenuExtension owner) { api.itemProperties().register("bookData", PropBookData.class, new PropBookData.Serializer(), owner); api.itemProperties().register("bannerData", PropBannerData.class, new PropBannerData.Serializer(), owner); api.itemProperties().register("shieldData", PropShieldData.class, new PropShieldData.Serializer(), owner); + api.itemProperties().register("itemModel", PropItemModel.class, new PropItemModel.Serializer(), owner); + api.itemProperties().register("tooltipStyle", PropTooltipStyle.class, new PropTooltipStyle.Serializer(), owner); + api.itemProperties().register("maxStackSize", PropMaxStackSize.class, new PropMaxStackSize.Serializer(), owner); + api.itemProperties().register("rarity", PropRarity.class, new PropRarity.Serializer(), owner); api.itemProperties().register("model", PropModel.class, new PropModel.Serializer(), owner); api.itemProperties().register("enchantStore", PropEnchantStore.class, new PropEnchantStore.Serializer(), owner); api.itemProperties().register("recipes", PropKnowledgeBook.class, new PropKnowledgeBook.Serializer(), owner); diff --git a/plugin/src/main/java/ru/abstractmenus/data/BannerData.java b/plugin/src/main/java/ru/abstractmenus/data/BannerData.java index 854c329..f77329b 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/BannerData.java +++ b/plugin/src/main/java/ru/abstractmenus/data/BannerData.java @@ -4,12 +4,15 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import ru.abstractmenus.hocon.api.ConfigNode; -import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; -import ru.abstractmenus.hocon.api.serialize.NodeSerializer; +import io.papermc.paper.registry.RegistryAccess; +import io.papermc.paper.registry.RegistryKey; import org.bukkit.DyeColor; +import org.bukkit.NamespacedKey; import org.bukkit.block.banner.Pattern; import org.bukkit.block.banner.PatternType; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; +import ru.abstractmenus.hocon.api.serialize.NodeSerializer; import java.util.ArrayList; import java.util.List; @@ -50,7 +53,55 @@ private List parseFromJson(ConfigNode node, String json) throws NodeSer String pattern = object.get("Pattern").getAsString(); byte colorByte = object.get("Color").getAsByte(); - PatternType patternType = PatternType.getByIdentifier(pattern); + // Banner JSON uses compact pattern identifiers. + String patternKey = switch (pattern) { + case "b" -> "base"; + case "bl" -> "square_bottom_left"; + case "br" -> "square_bottom_right"; + case "tl" -> "square_top_left"; + case "tr" -> "square_top_right"; + case "bs" -> "stripe_bottom"; + case "ts" -> "stripe_top"; + case "ls" -> "stripe_left"; + case "rs" -> "stripe_right"; + case "cs" -> "stripe_center"; + case "ms" -> "stripe_middle"; + case "drs" -> "stripe_downright"; + case "dls" -> "stripe_downleft"; + case "ss" -> "small_stripes"; + case "cr" -> "cross"; + case "sc" -> "straight_cross"; + case "bt" -> "triangle_bottom"; + case "tt" -> "triangle_top"; + case "bts" -> "triangles_bottom"; + case "tts" -> "triangles_top"; + case "ld" -> "diagonal_left"; + case "rd" -> "diagonal_up_right"; + case "lud" -> "diagonal_up_left"; + case "rud" -> "diagonal_right"; + case "mc" -> "circle"; + case "mr" -> "rhombus"; + case "vh" -> "half_vertical"; + case "hh" -> "half_horizontal"; + case "vhr" -> "half_vertical_right"; + case "hhb" -> "half_horizontal_bottom"; + case "bo" -> "border"; + case "cbo" -> "curly_border"; + case "cre" -> "creeper"; + case "gra" -> "gradient"; + case "gru" -> "gradient_up"; + case "bri" -> "bricks"; + case "sku" -> "skull"; + case "flo" -> "flower"; + case "moj" -> "mojang"; + case "glb" -> "globe"; + case "pig" -> "piglin"; + case "flw" -> "flow"; + case "gus" -> "guster"; + default -> pattern; + }; + NamespacedKey key = NamespacedKey.fromString(patternKey); + PatternType patternType = key == null ? null : RegistryAccess.registryAccess().getRegistry(RegistryKey.BANNER_PATTERN).get(key); DyeColor color = DyeColor.getByWoolData(colorByte); if (patternType == null) throw new NodeSerializeException(node, "Cannot serialize banner pattern from JSON"); diff --git a/plugin/src/main/java/ru/abstractmenus/data/actions/ActionItemRefresh.java b/plugin/src/main/java/ru/abstractmenus/data/actions/ActionItemRefresh.java index aaa9b6a..04cbce0 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/actions/ActionItemRefresh.java +++ b/plugin/src/main/java/ru/abstractmenus/data/actions/ActionItemRefresh.java @@ -28,10 +28,13 @@ private ActionItemRefresh(TypeBool value, TypeInt ticks, TypeSlot slot) { @Override public void activate(Player player, Menu menu, Item clickedItem) { - Slot slot = ((InventoryItem) clickedItem).getSlot(player, menu); - + Slot slot; if (this.slot != null) { slot = this.slot.getSlot(player, menu); + } else if (clickedItem instanceof InventoryItem inventoryItem) { + slot = inventoryItem.getSlot(player, menu); + } else { + return; } if (ticks != null) { diff --git a/plugin/src/main/java/ru/abstractmenus/data/actions/ActionItemRemove.java b/plugin/src/main/java/ru/abstractmenus/data/actions/ActionItemRemove.java index 710da56..a824701 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/actions/ActionItemRemove.java +++ b/plugin/src/main/java/ru/abstractmenus/data/actions/ActionItemRemove.java @@ -35,32 +35,28 @@ public void activate(Player player, Menu menu, Item clickedItem) { ItemStack built = item.build(player, menu); if (!player.getInventory().removeItem(built).isEmpty()) { - if (built.isSimilar(player.getInventory().getHelmet())) { + if (built.getAmount() > 0 && built.isSimilar(player.getInventory().getHelmet())) { decrement(built, player.getInventory().getHelmet(), () -> player.getInventory().setHelmet(null)); - continue; } - if (built.isSimilar(player.getInventory().getChestplate())) { + if (built.getAmount() > 0 && built.isSimilar(player.getInventory().getChestplate())) { decrement(built, player.getInventory().getChestplate(), () -> player.getInventory().setChestplate(null)); - continue; } - if (built.isSimilar(player.getInventory().getLeggings())) { + if (built.getAmount() > 0 && built.isSimilar(player.getInventory().getLeggings())) { decrement(built, player.getInventory().getLeggings(), () -> player.getInventory().setLeggings(null)); - continue; } - if (built.isSimilar(player.getInventory().getBoots())) { + if (built.getAmount() > 0 && built.isSimilar(player.getInventory().getBoots())) { decrement(built, player.getInventory().getBoots(), () -> player.getInventory().setBoots(null)); - continue; } try { - if (built.isSimilar(player.getInventory().getItemInOffHand())) { + if (built.getAmount() > 0 && built.isSimilar(player.getInventory().getItemInOffHand())) { decrement(built, player.getInventory().getItemInOffHand(), () -> player.getInventory().setItemInOffHand(null)); } @@ -73,11 +69,13 @@ public void activate(Player player, Menu menu, Item clickedItem) { } private void decrement(ItemStack item, ItemStack inSlot, Runnable clearCommand) { - if (inSlot != null && inSlot.getAmount() > item.getAmount()) { - inSlot.setAmount(inSlot.getAmount() - item.getAmount()); + int removed = Math.min(item.getAmount(), inSlot.getAmount()); + if (inSlot.getAmount() > removed) { + inSlot.setAmount(inSlot.getAmount() - removed); } else { clearCommand.run(); } + item.setAmount(item.getAmount() - removed); } public static class Serializer implements NodeSerializer { diff --git a/plugin/src/main/java/ru/abstractmenus/data/actions/ActionSound.java b/plugin/src/main/java/ru/abstractmenus/data/actions/ActionSound.java index 707bb50..7aff0ff 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/actions/ActionSound.java +++ b/plugin/src/main/java/ru/abstractmenus/data/actions/ActionSound.java @@ -74,18 +74,20 @@ public ActionSound deserialize(Class type, ConfigNode node) throws TypeFloat volume = getNodeValue(node, "volume", new TypeFloat(1.0f)); TypeFloat pitch = getNodeValue(node, "pitch", new TypeFloat(1.0f)); TypeBool isPublic = getNodeValue(node, "public", new TypeBool(false)); - TypeLocation location = getNodeValue(node, "location", null); + TypeLocation location = node.node("location").rawValue() == null + ? null : node.node("location").getValue(TypeLocation.class); return new ActionSound(sound, volume, pitch, isPublic, location); } private boolean isValidSoundName(String name) { + if (StringUtil.contains(name, '%')) return true; NamespacedKey namespacedKey = NamespacedKey.fromString(name); if (namespacedKey == null) { return false; } - return StringUtil.contains(name, '%') || Registry.SOUNDS.get(namespacedKey) != null; + return Registry.SOUNDS.get(namespacedKey) != null; } @SuppressWarnings("unchecked") diff --git a/plugin/src/main/java/ru/abstractmenus/data/actions/ActionSoundCustom.java b/plugin/src/main/java/ru/abstractmenus/data/actions/ActionSoundCustom.java index 3cfce75..9a1ff7f 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/actions/ActionSoundCustom.java +++ b/plugin/src/main/java/ru/abstractmenus/data/actions/ActionSoundCustom.java @@ -85,7 +85,7 @@ public ActionSoundCustom deserialize(Class type, ConfigNode node) throws NodeSer TypeLocation location = null; if (node.node("category").rawValue() != null) { - String name = node.node("name").getString(node.getString()); + String name = node.node("category").getString(); try { category = new TypeEnum<>(SoundCategory.valueOf(name)); diff --git a/plugin/src/main/java/ru/abstractmenus/data/actions/wrappers/ActionPlayerScope.java b/plugin/src/main/java/ru/abstractmenus/data/actions/wrappers/ActionPlayerScope.java index 1de374e..d7659ef 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/actions/wrappers/ActionPlayerScope.java +++ b/plugin/src/main/java/ru/abstractmenus/data/actions/wrappers/ActionPlayerScope.java @@ -7,6 +7,7 @@ import ru.abstractmenus.api.inventory.Item; import ru.abstractmenus.api.inventory.Menu; import ru.abstractmenus.data.Actions; +import ru.abstractmenus.util.bukkit.BukkitTasks; import ru.abstractmenus.hocon.api.ConfigNode; import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; import ru.abstractmenus.hocon.api.serialize.NodeSerializer; @@ -27,7 +28,7 @@ public void activate(Player player, Menu menu, Item clickedItem) { Player target = Bukkit.getPlayerExact(replacedName); if (target != null && target.isOnline()) { - actions.activate(target, menu, clickedItem); + BukkitTasks.runForEntity(target, () -> actions.activate(target, menu, clickedItem)); } } diff --git a/plugin/src/main/java/ru/abstractmenus/data/activators/OpenChat.java b/plugin/src/main/java/ru/abstractmenus/data/activators/OpenChat.java index 15ee88a..a474123 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/activators/OpenChat.java +++ b/plugin/src/main/java/ru/abstractmenus/data/activators/OpenChat.java @@ -5,7 +5,6 @@ import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; import ru.abstractmenus.hocon.api.serialize.NodeSerializer; import org.bukkit.event.EventHandler; -import org.bukkit.event.player.AsyncPlayerChatEvent; import ru.abstractmenus.api.Activator; import ru.abstractmenus.api.AbstractMenusApi; import ru.abstractmenus.util.bukkit.BukkitTasks; @@ -22,14 +21,17 @@ private OpenChat(List messages) { @EventHandler public void onChat(AsyncChatEvent event) { - for (String str : messages) { - String replaced = AbstractMenusApi.get().providers().placeholders().resolve().replace(event.getPlayer(), str); - - if (event.signedMessage().message().equalsIgnoreCase(replaced)) { - BukkitTasks.runTask(() -> openMenu(null, event.getPlayer())); - return; + String message = event.signedMessage().message(); + var player = event.getPlayer(); + BukkitTasks.runForEntity(player, () -> { + for (String str : messages) { + String replaced = AbstractMenusApi.get().providers().placeholders().resolve().replace(player, str); + if (message.equalsIgnoreCase(replaced)) { + openMenu(null, player); + return; + } } - } + }); } public static class Serializer implements NodeSerializer { diff --git a/plugin/src/main/java/ru/abstractmenus/data/activators/OpenChatContains.java b/plugin/src/main/java/ru/abstractmenus/data/activators/OpenChatContains.java index d5944a6..d49d870 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/activators/OpenChatContains.java +++ b/plugin/src/main/java/ru/abstractmenus/data/activators/OpenChatContains.java @@ -6,7 +6,6 @@ import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; import ru.abstractmenus.hocon.api.serialize.NodeSerializer; import org.bukkit.event.EventHandler; -import org.bukkit.event.player.AsyncPlayerChatEvent; import ru.abstractmenus.api.Activator; import ru.abstractmenus.api.AbstractMenusApi; import ru.abstractmenus.util.bukkit.BukkitTasks; @@ -23,14 +22,17 @@ private OpenChatContains(List messages) { @EventHandler public void onChat(AsyncChatEvent event) { - for (String str : messages) { - String msg = AbstractMenusApi.get().providers().placeholders().resolve().replace(event.getPlayer(), str); - - if (event.signedMessage().message().contains(msg)) { - BukkitTasks.runTask(() -> openMenu(null, event.getPlayer())); - return; + String message = event.signedMessage().message(); + var player = event.getPlayer(); + BukkitTasks.runForEntity(player, () -> { + for (String str : messages) { + String replaced = AbstractMenusApi.get().providers().placeholders().resolve().replace(player, str); + if (message.contains(replaced)) { + openMenu(null, player); + return; + } } - } + }); } public static class Serializer implements NodeSerializer { diff --git a/plugin/src/main/java/ru/abstractmenus/data/activators/OpenJoin.java b/plugin/src/main/java/ru/abstractmenus/data/activators/OpenJoin.java index daeaacc..3256936 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/activators/OpenJoin.java +++ b/plugin/src/main/java/ru/abstractmenus/data/activators/OpenJoin.java @@ -20,7 +20,7 @@ private OpenJoin(boolean value) { @EventHandler public void onPlayerJoin(PlayerJoinEvent event) { if (open) { - BukkitTasks.runTaskLater(() -> openMenu(null, event.getPlayer()), 20L); + BukkitTasks.runForEntityLater(event.getPlayer(), () -> openMenu(null, event.getPlayer()), 20L); } } diff --git a/plugin/src/main/java/ru/abstractmenus/data/activators/OpenPlate.java b/plugin/src/main/java/ru/abstractmenus/data/activators/OpenPlate.java index 9088952..5b1a895 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/activators/OpenPlate.java +++ b/plugin/src/main/java/ru/abstractmenus/data/activators/OpenPlate.java @@ -27,7 +27,7 @@ private OpenPlate(List location) { @EventHandler public void onPlateEnter(PlayerInteractEvent event) { Block clickedBlock = event.getClickedBlock(); - if (!ActivatorUtil.checkHand(event) || clickedBlock == null) { + if (clickedBlock == null) { return; } diff --git a/plugin/src/main/java/ru/abstractmenus/data/activators/OpenShiftClickEntity.java b/plugin/src/main/java/ru/abstractmenus/data/activators/OpenShiftClickEntity.java index ce8d688..01a51fc 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/activators/OpenShiftClickEntity.java +++ b/plugin/src/main/java/ru/abstractmenus/data/activators/OpenShiftClickEntity.java @@ -38,13 +38,12 @@ public void onEntityClick(PlayerInteractEntityEvent event) { continue; } - if (data.getName() == null) { + if (data.getName() != null) { String name = AbstractMenusApi.get().providers().placeholders().resolve().replace(player, data.getName()); - if (clickedEntity.getName().equalsIgnoreCase(name)) { - openMenu(clickedEntity, player); - return; - } + if (!clickedEntity.getName().equalsIgnoreCase(name)) continue; } + openMenu(clickedEntity, player); + return; } } diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropAttributeModifier.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropAttributeModifier.java index 03a65c3..c08d277 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/properties/PropAttributeModifier.java +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropAttributeModifier.java @@ -6,6 +6,7 @@ import org.bukkit.attribute.AttributeModifier; import org.bukkit.entity.Player; import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.EquipmentSlotGroup; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; import ru.abstractmenus.api.inventory.ItemProperty; @@ -14,15 +15,19 @@ import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; import ru.abstractmenus.hocon.api.serialize.NodeSerializer; -import java.util.HashMap; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Locale; +import java.util.UUID; import java.util.List; import java.util.Map; public class PropAttributeModifier implements ItemProperty { - private final Map modifiers; + private final List> modifiers; - private PropAttributeModifier(Map modifiers) { + private PropAttributeModifier(List> modifiers) { this.modifiers = modifiers; } @@ -38,7 +43,9 @@ public boolean isApplyMeta() { @Override public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { - for (Map.Entry entry : modifiers.entrySet()) { + for (Map.Entry entry : modifiers) { + Collection existing = meta.getAttributeModifiers(entry.getKey()); + if (existing != null && existing.stream().anyMatch(modifier -> modifier.getKey().equals(entry.getValue().getKey()))) continue; meta.addAttributeModifier(entry.getKey(), entry.getValue()); } } @@ -50,12 +57,20 @@ public static class Serializer implements NodeSerializer @SuppressWarnings("UnstableApiUsage") public PropAttributeModifier deserialize(Class type, ConfigNode node) throws NodeSerializeException { List nodes = node.childrenList(); - Map map = new HashMap<>(); + List> modifiers = new ArrayList<>(); for (ConfigNode entry : nodes) { try { String modifierType = entry.node("type").getString(); - Attribute attribute = Registry.ATTRIBUTE.getOrThrow(NamespacedKey.minecraft(modifierType.toLowerCase())); + NamespacedKey attributeKey = NamespacedKey.fromString(modifierType.toLowerCase(Locale.ROOT)); + if (attributeKey != null) { + String name = attributeKey.getKey().replaceFirst("^(generic|player|zombie)\\.", ""); + attributeKey = new NamespacedKey(attributeKey.getNamespace(), name); + } + Attribute attribute = attributeKey == null ? null : Registry.ATTRIBUTE.get(attributeKey); + if (attribute == null) { + throw new NodeSerializeException(entry, "Unknown attribute in attributeModifier: " + modifierType); + } double amount = entry.node("amount").getDouble(0); AttributeModifier.Operation operation = AttributeModifier.Operation.valueOf(entry.node("operation").getString("add_number").toUpperCase()); @@ -68,7 +83,10 @@ public PropAttributeModifier deserialize(Class type, ConfigNode node) throws Nod } } - NamespacedKey key = NamespacedKey.minecraft("custom_modifier_" + attribute.getKey().getKey()); + String content = attributeKey + "|" + Double.toHexString(amount) + "|" + operation.name() + + "|" + (slot == null ? EquipmentSlotGroup.ANY : slot.getGroup()); + UUID entryKey = UUID.nameUUIDFromBytes(content.getBytes(StandardCharsets.UTF_8)); + NamespacedKey key = NamespacedKey.minecraft("custom_modifier_" + modifiers.size() + "_" + entryKey); AttributeModifier modifier; if (slot != null) { @@ -77,13 +95,13 @@ public PropAttributeModifier deserialize(Class type, ConfigNode node) throws Nod modifier = new AttributeModifier(key, amount, operation); } - map.put(attribute, modifier); + modifiers.add(Map.entry(attribute, modifier)); } catch (IllegalArgumentException e) { - throw new NodeSerializeException("Invalid attribute or operation: " + e.getMessage(), e); + throw new NodeSerializeException(entry, "Invalid attribute or operation in attributeModifier: " + e.getMessage()); } } - return new PropAttributeModifier(map); + return new PropAttributeModifier(modifiers); } } } \ No newline at end of file diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropCount.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropCount.java index ebc02bf..9db5aa2 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/properties/PropCount.java +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropCount.java @@ -31,7 +31,6 @@ public boolean isApplyMeta() { @Override public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { itemStack.setAmount(amount.getInt(player, menu)); - } public static class Serializer implements NodeSerializer { diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropDamage.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropDamage.java index 6dcf20f..fdeba8d 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/properties/PropDamage.java +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropDamage.java @@ -14,17 +14,8 @@ public class PropDamage implements ItemProperty { private final TypeInt damage; - private static boolean legacy; - static { - try { - Class.forName("org.bukkit.inventory.meta.Damageable"); - } catch (Exception e) { - legacy = true; - } - } - - private PropDamage(TypeInt damage){ + private PropDamage(TypeInt damage) { this.damage = damage; } @@ -35,20 +26,13 @@ public boolean canReplaceMaterial() { @Override public boolean isApplyMeta() { - return !legacy; + return true; } @Override public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { - int dmg = damage.getInt(player, menu); - - if (legacy) { - itemStack.setDurability((short) dmg); - } else { - if (meta instanceof Damageable) { - Damageable damageable = (Damageable) meta; - damageable.setDamage(dmg); - } + if (meta instanceof Damageable damageable) { + damageable.setDamage(damage.getInt(player, menu)); } } diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropData.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropData.java index 1b314ba..613f7f7 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/properties/PropData.java +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropData.java @@ -5,6 +5,7 @@ import ru.abstractmenus.hocon.api.serialize.NodeSerializer; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; import org.bukkit.inventory.meta.ItemMeta; import ru.abstractmenus.api.inventory.Menu; import ru.abstractmenus.api.inventory.ItemProperty; @@ -25,14 +26,14 @@ public boolean canReplaceMaterial() { @Override public boolean isApplyMeta() { - return false; + return true; } @Override public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { - byte resolved = data.getByte(player, menu); - itemStack.getData().setData(resolved); - itemStack.setDurability(resolved); + if (meta instanceof Damageable damageable) { + damageable.setDamage(data.getByte(player, menu)); + } } public static class Serializer implements NodeSerializer { diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropFlags.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropFlags.java index e99838d..2f525f6 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/properties/PropFlags.java +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropFlags.java @@ -1,13 +1,9 @@ package ru.abstractmenus.data.properties; -import com.google.common.collect.Multimap; -import org.bukkit.attribute.Attribute; -import org.bukkit.attribute.AttributeModifier; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; -import ru.abstractmenus.api.Logger; import ru.abstractmenus.api.inventory.ItemProperty; import ru.abstractmenus.api.inventory.Menu; import ru.abstractmenus.datatype.TypeEnum; @@ -16,7 +12,6 @@ import ru.abstractmenus.hocon.api.serialize.NodeSerializer; import ru.abstractmenus.util.StringUtil; -import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -42,28 +37,10 @@ public boolean isApplyMeta() { @Override public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { for (TypeEnum flag : flags) { - Method method = getMethodForAttributeModifiers(itemStack); - if (method != null) { - try { - @SuppressWarnings("unchecked") - Multimap attributeModifiers = (Multimap) method.invoke(itemStack.getType()); - meta.setAttributeModifiers(attributeModifiers); - } catch (Exception e) { - Logger.warning("Failed to apply attribute modifiers: " + e.getMessage()); - } - } meta.addItemFlags(flag.getEnum(ItemFlag.class, player, menu)); } } - private Method getMethodForAttributeModifiers(ItemStack itemStack) { - try { - return itemStack.getType().getClass().getMethod("getDefaultAttributeModifiers"); - } catch (NoSuchMethodException e) { - return null; - } - } - public static class Serializer implements NodeSerializer { @Override @@ -96,4 +73,4 @@ private TypeEnum getItemFlag(ConfigNode node, String value) throws Nod } } } -} \ No newline at end of file +} diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropGlow.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropGlow.java index 6f64920..dfa2375 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/properties/PropGlow.java +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropGlow.java @@ -4,9 +4,7 @@ import ru.abstractmenus.hocon.api.ConfigNode; import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; import ru.abstractmenus.hocon.api.serialize.NodeSerializer; -import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; import ru.abstractmenus.api.inventory.Menu; @@ -16,18 +14,8 @@ public class PropGlow implements ItemProperty { private final TypeBool glow; - private Enchantment durabilityEnch; - - private PropGlow(TypeBool glow){ + private PropGlow(TypeBool glow) { this.glow = glow; - - try { - durabilityEnch = (Enchantment) Enchantment.class - .getField("DURABILITY") - .get(null); - } catch (Throwable t) { - // Ignore - } } @Override @@ -42,14 +30,8 @@ public boolean isApplyMeta() { @Override public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { - if (glow.getBool(player, null)) { - if (durabilityEnch != null) { - meta.addEnchant(durabilityEnch, 1, true); - } else { - meta.addEnchant(Enchantment.UNBREAKING, 1, true); - } - - meta.addItemFlags(ItemFlag.HIDE_ENCHANTS); + if (glow.getBool(player, menu)) { + meta.setEnchantmentGlintOverride(true); } } diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropItemModel.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropItemModel.java new file mode 100644 index 0000000..649fd0e --- /dev/null +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropItemModel.java @@ -0,0 +1,47 @@ +package ru.abstractmenus.data.properties; + +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import ru.abstractmenus.api.inventory.ItemProperty; +import ru.abstractmenus.api.inventory.Menu; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; +import ru.abstractmenus.hocon.api.serialize.NodeSerializer; + +public class PropItemModel implements ItemProperty { + + private final NamespacedKey itemModel; + + private PropItemModel(NamespacedKey itemModel) { + this.itemModel = itemModel; + } + + @Override + public boolean canReplaceMaterial() { + return false; + } + + @Override + public boolean isApplyMeta() { + return true; + } + + @Override + public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { + meta.setItemModel(itemModel); + } + + public static class Serializer implements NodeSerializer { + + @Override + public PropItemModel deserialize(Class type, ConfigNode node) throws NodeSerializeException { + NamespacedKey key = NamespacedKey.fromString(node.getString("")); + if (key == null) { + throw new NodeSerializeException(node, "Invalid namespaced key"); + } + return new PropItemModel(key); + } + } +} diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropMaxStackSize.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropMaxStackSize.java new file mode 100644 index 0000000..e63dde1 --- /dev/null +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropMaxStackSize.java @@ -0,0 +1,57 @@ +package ru.abstractmenus.data.properties; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import ru.abstractmenus.api.Logger; +import ru.abstractmenus.api.inventory.ItemProperty; +import ru.abstractmenus.api.inventory.Menu; +import ru.abstractmenus.datatype.DataType; +import ru.abstractmenus.datatype.TypeInt; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; +import ru.abstractmenus.hocon.api.serialize.NodeSerializer; + +public class PropMaxStackSize implements ItemProperty { + + private final TypeInt maxStackSize; + + private PropMaxStackSize(TypeInt maxStackSize) { + this.maxStackSize = maxStackSize; + } + + @Override + public boolean canReplaceMaterial() { + return false; + } + + @Override + public boolean isApplyMeta() { + return true; + } + + @Override + public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { + int size = maxStackSize.getInt(player, menu); + if (size < 1 || size > 99) { + Logger.warning("Skipping maxStackSize value outside 1..99: " + size); + return; + } + meta.setMaxStackSize(size); + } + + public static class Serializer implements NodeSerializer { + + @Override + public PropMaxStackSize deserialize(Class type, ConfigNode node) throws NodeSerializeException { + TypeInt value = new TypeInt.Serializer().deserialize(TypeInt.class, node); + if (!DataType.hasPlaceholder(node.getString(""))) { + int size = value.getInt(null, null); + if (size < 1 || size > 99) { + throw new NodeSerializeException(node, "maxStackSize must be between 1 and 99"); + } + } + return new PropMaxStackSize(value); + } + } +} diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropModel.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropModel.java index 794a786..2f5ee0e 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/properties/PropModel.java +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropModel.java @@ -1,21 +1,38 @@ package ru.abstractmenus.data.properties; -import ru.abstractmenus.hocon.api.ConfigNode; -import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; -import ru.abstractmenus.hocon.api.serialize.NodeSerializer; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; -import ru.abstractmenus.api.inventory.Menu; +import org.bukkit.inventory.meta.components.CustomModelDataComponent; +import ru.abstractmenus.api.AbstractMenusApi; import ru.abstractmenus.api.inventory.ItemProperty; +import ru.abstractmenus.api.inventory.Menu; +import ru.abstractmenus.datatype.DataType; +import ru.abstractmenus.datatype.TypeBool; +import ru.abstractmenus.datatype.TypeColor; +import ru.abstractmenus.datatype.TypeFloat; import ru.abstractmenus.datatype.TypeInt; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; +import ru.abstractmenus.hocon.api.serialize.NodeSerializer; + +import java.util.ArrayList; +import java.util.List; public class PropModel implements ItemProperty { private final TypeInt model; + private final List floats; + private final List flags; + private final List strings; + private final List colors; - private PropModel(TypeInt model){ + private PropModel(TypeInt model, List floats, List flags, List strings, List colors) { this.model = model; + this.floats = floats; + this.flags = flags; + this.strings = strings; + this.colors = colors; } @Override @@ -30,19 +47,43 @@ public boolean isApplyMeta() { @Override public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { - try{ - meta.setCustomModelData(model.getInt(player, menu)); - } catch (Exception e){ - /* Ignore */ - } + CustomModelDataComponent component = meta.getCustomModelDataComponent(); + component.setFloats(model != null ? List.of((float) model.getInt(player, menu)) : floats.stream().map(value -> value.getFloat(player, menu)).toList()); + component.setFlags(flags.stream().map(value -> value.getBool(player, menu)).toList()); + component.setStrings(strings.stream().map(value -> DataType.hasPlaceholder(value) ? AbstractMenusApi.get().providers().placeholders().resolve().replace(player, value) : value).toList()); + component.setColors(colors.stream().map(value -> value.getColor(player, menu)).toList()); + meta.setCustomModelDataComponent(component); } public static class Serializer implements NodeSerializer { @Override public PropModel deserialize(Class type, ConfigNode node) throws NodeSerializeException { - return new PropModel(node.getValue(TypeInt.class)); + if (node.isPrimitive()) { + return new PropModel(new TypeInt.Serializer().deserialize(TypeInt.class, node), List.of(), List.of(), List.of(), List.of()); + } + if (node.isList() || node.rawValue() == null) { + throw new NodeSerializeException(node, "model must be an integer or an object"); + } + return new PropModel(null, + readList(node.node("floats"), TypeFloat.class, new TypeFloat.Serializer()), + readList(node.node("flags"), TypeBool.class, new TypeBool.Serializer()), + readList(node.node("strings"), String.class, (stringType, entry) -> entry.getString()), + readList(node.node("colors"), TypeColor.class, new TypeColor.Serializer())); } + private List readList(ConfigNode node, Class type, NodeSerializer serializer) throws NodeSerializeException { + if (node.rawValue() == null) { + return List.of(); + } + if (!node.isList()) { + throw new NodeSerializeException(node, "Model component field must be a list"); + } + List values = new ArrayList<>(); + for (ConfigNode entry : node.childrenList()) { + values.add(serializer.deserialize(type, entry)); + } + return values; + } } } diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropRarity.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropRarity.java new file mode 100644 index 0000000..b6531f4 --- /dev/null +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropRarity.java @@ -0,0 +1,55 @@ +package ru.abstractmenus.data.properties; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemRarity; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import ru.abstractmenus.api.inventory.ItemProperty; +import ru.abstractmenus.api.inventory.Menu; +import ru.abstractmenus.datatype.DataType; +import ru.abstractmenus.datatype.TypeEnum; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; +import ru.abstractmenus.hocon.api.serialize.NodeSerializer; + +import java.util.Locale; + +public class PropRarity implements ItemProperty { + + private final TypeEnum rarity; + + private PropRarity(TypeEnum rarity) { + this.rarity = rarity; + } + + @Override + public boolean canReplaceMaterial() { + return false; + } + + @Override + public boolean isApplyMeta() { + return true; + } + + @Override + public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { + meta.setRarity(rarity.getEnum(ItemRarity.class, player, menu)); + } + + public static class Serializer implements NodeSerializer { + + @Override + public PropRarity deserialize(Class type, ConfigNode node) throws NodeSerializeException { + String value = node.getString(""); + if (DataType.hasPlaceholder(value)) { + return new PropRarity(new TypeEnum<>(value)); + } + try { + return new PropRarity(new TypeEnum<>(ItemRarity.valueOf(value.toUpperCase(Locale.ROOT)))); + } catch (IllegalArgumentException e) { + throw new NodeSerializeException(node, "Unknown item rarity '" + value + "'"); + } + } + } +} diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropTooltipDisplay.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropTooltipDisplay.java index cb3159b..c3347c4 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/properties/PropTooltipDisplay.java +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropTooltipDisplay.java @@ -3,6 +3,7 @@ import io.papermc.paper.datacomponent.DataComponentType; import io.papermc.paper.datacomponent.DataComponentTypes; import io.papermc.paper.datacomponent.item.TooltipDisplay; +import org.bukkit.NamespacedKey; import org.bukkit.Registry; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -14,25 +15,10 @@ import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; import ru.abstractmenus.hocon.api.serialize.NodeSerializer; -import java.util.Collections; -import java.util.HashMap; import java.util.LinkedHashSet; import java.util.Locale; -import java.util.Map; import java.util.Set; -/** - * Item property controlling the {@code tooltip_display} data component - * ({@link TooltipDisplay}). Hides a tooltip entirely via {@code hideTooltip} - * or hides the tooltip of specific components via {@code hiddenComponents}. - * - *
{@code
- * tooltipDisplay {
- *     hideTooltip: false
- *     hiddenComponents: [BUNDLE_CONTENTS, ENCHANTMENTS]
- * }
- * }
- */ public class PropTooltipDisplay implements ItemProperty { private final TypeBool hideTooltip; @@ -50,7 +36,7 @@ public boolean canReplaceMaterial() { @Override public boolean isApplyMeta() { - return true; + return false; } @Override @@ -69,9 +55,13 @@ private void tooltipDisplay(ItemStack itemStack, Player player, Menu menu) { ? hideTooltip.getBool(player, menu) : (existing != null && existing.hideTooltip()); - Set hidden = (hiddenComponents != null) - ? hiddenComponents - : (existing != null ? existing.hiddenComponents() : Collections.emptySet()); + Set hidden = new LinkedHashSet<>(); + if (existing != null) { + hidden.addAll(existing.hiddenComponents()); + } + if (hiddenComponents != null) { + hidden.addAll(hiddenComponents); + } itemStack.setData(DataComponentTypes.TOOLTIP_DISPLAY, TooltipDisplay.tooltipDisplay() .hideTooltip(hide) @@ -112,65 +102,11 @@ public PropTooltipDisplay deserialize(Class type, ConfigNode node) throws NodeSe } - /** - * Test hook: replaces the live {@link Registry#DATA_COMPONENT_TYPE} source. - * Accepts any {@link Iterable} so tests do not need to mock the Registry - * class itself (its static initializer requires a live Paper server). - */ - static void configureRegistry(Iterable registry) { - registryOverride = registry; - cache = null; - } - - /** - * Test hook: restores the live registry source and clears the cache. - */ - static void resetRegistry() { - registryOverride = null; - cache = null; - } - - static DataComponentType resolveComponent(String raw) { + private static DataComponentType resolveComponent(String raw) { if (raw == null) { return null; } - String key = raw.trim().toLowerCase(Locale.ROOT); - int colon = key.indexOf(':'); - if (colon >= 0) { - key = key.substring(colon + 1); - } - if (key.isEmpty()) { - return null; - } - return registryMap().get(key); - } - - private static volatile Iterable registryOverride; - private static volatile Map cache; - - private static Map registryMap() { - Map local = cache; - if (local != null) { - return local; - } - Iterable source = registryOverride; - if (source == null) { - try { - source = Registry.DATA_COMPONENT_TYPE; - } catch (Throwable t) { - source = null; - } - } - Map built = new HashMap<>(); - if (source != null) { - for (DataComponentType type : source) { - if (type.getKey() == null) { - continue; - } - built.put(type.getKey().getKey().toLowerCase(Locale.ROOT), type); - } - } - cache = built; - return built; + NamespacedKey key = NamespacedKey.fromString(raw.trim().toLowerCase(Locale.ROOT)); + return key == null ? null : Registry.DATA_COMPONENT_TYPE.get(key); } } diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropTooltipStyle.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropTooltipStyle.java new file mode 100644 index 0000000..14a1a93 --- /dev/null +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropTooltipStyle.java @@ -0,0 +1,47 @@ +package ru.abstractmenus.data.properties; + +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import ru.abstractmenus.api.inventory.ItemProperty; +import ru.abstractmenus.api.inventory.Menu; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; +import ru.abstractmenus.hocon.api.serialize.NodeSerializer; + +public class PropTooltipStyle implements ItemProperty { + + private final NamespacedKey tooltipStyle; + + private PropTooltipStyle(NamespacedKey tooltipStyle) { + this.tooltipStyle = tooltipStyle; + } + + @Override + public boolean canReplaceMaterial() { + return false; + } + + @Override + public boolean isApplyMeta() { + return true; + } + + @Override + public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { + meta.setTooltipStyle(tooltipStyle); + } + + public static class Serializer implements NodeSerializer { + + @Override + public PropTooltipStyle deserialize(Class type, ConfigNode node) throws NodeSerializeException { + NamespacedKey key = NamespacedKey.fromString(node.getString("")); + if (key == null) { + throw new NodeSerializeException(node, "Invalid namespaced key"); + } + return new PropTooltipStyle(key); + } + } +} diff --git a/plugin/src/main/java/ru/abstractmenus/data/rules/RuleChance.java b/plugin/src/main/java/ru/abstractmenus/data/rules/RuleChance.java index afcff17..6c950d9 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/rules/RuleChance.java +++ b/plugin/src/main/java/ru/abstractmenus/data/rules/RuleChance.java @@ -21,7 +21,7 @@ private RuleChance(TypeInt chance){ @Override public boolean check(Player player, Menu menu, Item clickedItem) { - return ThreadLocalRandom.current().nextInt(100) <= chance.getInt(player, menu); + return ThreadLocalRandom.current().nextInt(100) < chance.getInt(player, menu); } public static class Serializer implements NodeSerializer { diff --git a/plugin/src/main/java/ru/abstractmenus/data/rules/RuleInventoryItem.java b/plugin/src/main/java/ru/abstractmenus/data/rules/RuleInventoryItem.java index 71c96e2..d078b2c 100644 --- a/plugin/src/main/java/ru/abstractmenus/data/rules/RuleInventoryItem.java +++ b/plugin/src/main/java/ru/abstractmenus/data/rules/RuleInventoryItem.java @@ -30,6 +30,7 @@ public boolean check(Player player, Menu menu, Item clickedItem) { if (item instanceof InventoryItem) { try { ItemStack built = item.build(player, menu); + if (built == null) return false; Slot slot = ((InventoryItem)item).getSlot(player, menu); for (int index : SlotUtil.collect(slot)) { @@ -39,13 +40,16 @@ public boolean check(Player player, Menu menu, Item clickedItem) { } } catch (Exception e){ Logger.severe("Cannot check item in player inventory: " + e.getMessage()); + return false; } } else { try { ItemStack built = item.build(player, menu); + if (built == null) return false; if(!player.getInventory().containsAtLeast(built, built.getAmount())) return false; } catch (Exception e){ Logger.severe("Cannot check item in player inventory: " + e.getMessage()); + return false; } } } diff --git a/plugin/src/main/java/ru/abstractmenus/datatype/TypeColor.java b/plugin/src/main/java/ru/abstractmenus/datatype/TypeColor.java index fea3d30..3599948 100644 --- a/plugin/src/main/java/ru/abstractmenus/datatype/TypeColor.java +++ b/plugin/src/main/java/ru/abstractmenus/datatype/TypeColor.java @@ -51,7 +51,7 @@ private Color parseColor(Player player, Menu menu) throws IllegalArgumentExcepti ); } - return DyeColor.valueOf(str).getColor(); + return DyeColor.valueOf(str.toUpperCase(java.util.Locale.ROOT)).getColor(); } throw new IllegalArgumentException("Cannot deserialize color. You need to specify color in single string. Check our wiki"); diff --git a/plugin/src/main/java/ru/abstractmenus/extractors/BlockExtractor.java b/plugin/src/main/java/ru/abstractmenus/extractors/BlockExtractor.java index d54b35f..d7aad22 100644 --- a/plugin/src/main/java/ru/abstractmenus/extractors/BlockExtractor.java +++ b/plugin/src/main/java/ru/abstractmenus/extractors/BlockExtractor.java @@ -1,8 +1,12 @@ package ru.abstractmenus.extractors; +import io.papermc.paper.registry.RegistryAccess; +import io.papermc.paper.registry.RegistryKey; import org.bukkit.block.Block; import ru.abstractmenus.api.ValueExtractor; +import java.util.Locale; + public class BlockExtractor implements ValueExtractor { public static final BlockExtractor INSTANCE = new BlockExtractor(); @@ -15,14 +19,14 @@ public String extract(Object obj, String placeholder) { return switch (placeholder) { default -> null; case "block_type" -> block.getType().toString(); - case "block_data" -> String.valueOf(block.getData()); // 1.12- + case "block_data" -> block.getBlockData().getAsString(); case "block_world" -> block.getWorld().getName(); case "block_x" -> String.valueOf(block.getX()); case "block_y" -> String.valueOf(block.getY()); case "block_z" -> String.valueOf(block.getZ()); case "block_power" -> String.valueOf(block.getBlockPower()); case "block_temp" -> String.valueOf(block.getTemperature()); - case "block_biome" -> block.getBiome().name(); + case "block_biome" -> RegistryAccess.registryAccess().getRegistry(RegistryKey.BIOME).getKeyOrThrow(block.getBiome()).getKey().toUpperCase(Locale.ROOT); }; } diff --git a/plugin/src/main/java/ru/abstractmenus/extractors/EntityExtractor.java b/plugin/src/main/java/ru/abstractmenus/extractors/EntityExtractor.java index c5e24a2..e605fa4 100644 --- a/plugin/src/main/java/ru/abstractmenus/extractors/EntityExtractor.java +++ b/plugin/src/main/java/ru/abstractmenus/extractors/EntityExtractor.java @@ -12,16 +12,17 @@ public class EntityExtractor implements ValueExtractor { @Override public String extract(Object obj, String placeholder) { + if (obj instanceof Player && placeholder.startsWith("player_")) { + return PlayerExtractor.INSTANCE.extract(obj, placeholder); + } if (obj instanceof Entity entity) { if (entity instanceof LivingEntity livingEntity) { - return switch (placeholder) { - case "entity_last_damage" -> String.valueOf(livingEntity.getLastDamage()); - case "entity_no_damage_ticks" -> String.valueOf(livingEntity.getNoDamageTicks()); - case "entity_killer" -> - livingEntity.getKiller() != null ? livingEntity.getKiller().getName() : null; - case "entity_eye_height" -> String.valueOf(livingEntity.getEyeHeight()); - default -> ""; - }; + switch (placeholder) { + case "entity_last_damage": return String.valueOf(livingEntity.getLastDamage()); + case "entity_no_damage_ticks": return String.valueOf(livingEntity.getNoDamageTicks()); + case "entity_killer": return livingEntity.getKiller() != null ? livingEntity.getKiller().getName() : null; + case "entity_eye_height": return String.valueOf(livingEntity.getEyeHeight()); + } } return switch (placeholder) { @@ -40,8 +41,6 @@ public String extract(Object obj, String placeholder) { default -> ""; }; } - if (obj instanceof Player) - return PlayerExtractor.INSTANCE.extract(obj, placeholder); return ""; } diff --git a/plugin/src/main/java/ru/abstractmenus/extractors/ItemStackExtractor.java b/plugin/src/main/java/ru/abstractmenus/extractors/ItemStackExtractor.java index 56f982f..94de1f5 100644 --- a/plugin/src/main/java/ru/abstractmenus/extractors/ItemStackExtractor.java +++ b/plugin/src/main/java/ru/abstractmenus/extractors/ItemStackExtractor.java @@ -1,6 +1,7 @@ package ru.abstractmenus.extractors; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; import org.bukkit.inventory.meta.ItemMeta; import ru.abstractmenus.api.ValueExtractor; import ru.abstractmenus.util.bukkit.ItemUtil; @@ -16,7 +17,7 @@ public String extract(Object obj, String placeholder) { String result = switch (placeholder) { case "item_type" -> item.getType().toString(); - case "item_data" -> String.valueOf(item.getData().getData()); // 1.12- + case "item_data" -> String.valueOf(item.getItemMeta() instanceof Damageable damageable ? damageable.getDamage() : 0); case "item_amount" -> String.valueOf(item.getAmount()); case "item_max_stack" -> String.valueOf(item.getMaxStackSize()); case "item_serialized" -> ItemUtil.encodeStack(item); @@ -32,8 +33,8 @@ public String extract(Object obj, String placeholder) { if (meta != null) { return switch (placeholder) { case "item_display_name" -> meta.getDisplayName(); - case "item_localized_name" -> meta.getLocalizedName(); - case "item_model" -> String.valueOf(meta.getCustomModelData()); // 1.14+ + case "item_localized_name" -> meta.getDisplayName(); + case "item_model" -> meta.hasCustomModelData() ? String.valueOf(meta.getCustomModelData()) : ""; // 1.14+ default -> ""; }; } diff --git a/plugin/src/main/java/ru/abstractmenus/extractors/NPCExtractor.java b/plugin/src/main/java/ru/abstractmenus/extractors/NPCExtractor.java index f5f422b..f03695b 100644 --- a/plugin/src/main/java/ru/abstractmenus/extractors/NPCExtractor.java +++ b/plugin/src/main/java/ru/abstractmenus/extractors/NPCExtractor.java @@ -13,7 +13,7 @@ public String extract(Object obj, String placeholder) { NPC npc = (NPC) obj; if (placeholder.startsWith("npc_entity_")) { - return EntityExtractor.INSTANCE.extract(npc.getBukkitEntity(), placeholder.substring(4)); + return EntityExtractor.INSTANCE.extract(npc.getEntity(), placeholder.substring(4)); } return switch (placeholder) { diff --git a/plugin/src/main/java/ru/abstractmenus/handlers/LevelDefaultHandler.java b/plugin/src/main/java/ru/abstractmenus/handlers/LevelDefaultHandler.java index 962fa90..8f9736d 100644 --- a/plugin/src/main/java/ru/abstractmenus/handlers/LevelDefaultHandler.java +++ b/plugin/src/main/java/ru/abstractmenus/handlers/LevelDefaultHandler.java @@ -12,7 +12,7 @@ public int getLevel(Player player) { @Override public int getXp(Player player) { - return player.getTotalExperience(); + return player.calculateTotalExperiencePoints(); } @Override @@ -23,10 +23,7 @@ public void giveXp(Player player, int xp) { @Override public void takeXp(Player player, int xp) { int value = Math.max(0, getXp(player) - xp); - player.setTotalExperience(0); - player.setLevel(0); - player.setExp(0); - player.giveExp(value); + player.setExperienceLevelAndProgress(value); } @Override diff --git a/plugin/src/main/java/ru/abstractmenus/handlers/SkinsRestorerHandler.java b/plugin/src/main/java/ru/abstractmenus/handlers/SkinsRestorerHandler.java index 81a68cc..dbae523 100644 --- a/plugin/src/main/java/ru/abstractmenus/handlers/SkinsRestorerHandler.java +++ b/plugin/src/main/java/ru/abstractmenus/handlers/SkinsRestorerHandler.java @@ -31,6 +31,9 @@ public class SkinsRestorerHandler implements SkinHandler { public SkinsRestorerHandler(boolean isProxyMode, Plugin plugin) { this.isProxyMode = isProxyMode; this.plugin = plugin; + if (isProxyMode) { + plugin.getServer().getMessenger().registerOutgoingPluginChannel(plugin, "abstractmenus:main"); + } try { this.api = SkinsRestorerProvider.get(); this.applier = api.getSkinApplier(Player.class); diff --git a/plugin/src/main/java/ru/abstractmenus/handlers/placeholder/PlaceholderDefaultHandler.java b/plugin/src/main/java/ru/abstractmenus/handlers/placeholder/PlaceholderDefaultHandler.java index af19287..90ee460 100644 --- a/plugin/src/main/java/ru/abstractmenus/handlers/placeholder/PlaceholderDefaultHandler.java +++ b/plugin/src/main/java/ru/abstractmenus/handlers/placeholder/PlaceholderDefaultHandler.java @@ -14,7 +14,7 @@ public class PlaceholderDefaultHandler implements PlaceholderHandler { - private static final Pattern PATTERN = Pattern.compile("%(\\S+)%"); + private static final Pattern PATTERN = Pattern.compile("%([^%\\s]+)%"); private final Map hooks = new HashMap<>(); diff --git a/plugin/src/main/java/ru/abstractmenus/impl/AbstractMenusApiImpl.java b/plugin/src/main/java/ru/abstractmenus/impl/AbstractMenusApiImpl.java index a982dc9..b1d5dab 100644 --- a/plugin/src/main/java/ru/abstractmenus/impl/AbstractMenusApiImpl.java +++ b/plugin/src/main/java/ru/abstractmenus/impl/AbstractMenusApiImpl.java @@ -113,12 +113,6 @@ public Optional getOpenedMenu(Player player) { @Override public String apiVersion() { - // Paper 1.21+ exposes getPluginMeta().getVersion(); fall back to - // getDescription().getVersion() if the new API isn't present. - try { - return plugin.getPluginMeta().getVersion(); - } catch (NoSuchMethodError | AbstractMethodError ignored) { - return plugin.getDescription().getVersion(); - } + return plugin.getPluginMeta().getVersion(); } } diff --git a/plugin/src/main/java/ru/abstractmenus/impl/ProviderRegistryImpl.java b/plugin/src/main/java/ru/abstractmenus/impl/ProviderRegistryImpl.java index 3eac0c4..ebea32e 100644 --- a/plugin/src/main/java/ru/abstractmenus/impl/ProviderRegistryImpl.java +++ b/plugin/src/main/java/ru/abstractmenus/impl/ProviderRegistryImpl.java @@ -123,6 +123,8 @@ private static final class SectionImpl implements ProviderSection { @Override public synchronized void register(String id, T handler, int priority, MenuExtension extOwner) { String k = id.toLowerCase(); + keysByExtension.values().forEach(keys -> keys.remove(k)); + keysByExtension.entrySet().removeIf(entry -> entry.getValue().isEmpty()); byId.put(k, new Entry<>(handler, priority)); keysByExtension.computeIfAbsent(extOwner, o -> new HashSet<>()).add(k); } diff --git a/plugin/src/main/java/ru/abstractmenus/listeners/InventoryListener.java b/plugin/src/main/java/ru/abstractmenus/listeners/InventoryListener.java index 61490b0..10163d7 100644 --- a/plugin/src/main/java/ru/abstractmenus/listeners/InventoryListener.java +++ b/plugin/src/main/java/ru/abstractmenus/listeners/InventoryListener.java @@ -1,11 +1,10 @@ package ru.abstractmenus.listeners; -import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.inventory.*; -import org.bukkit.inventory.ItemStack; import ru.abstractmenus.api.inventory.Menu; import ru.abstractmenus.api.inventory.Slot; import ru.abstractmenus.menu.AbstractMenu; @@ -15,7 +14,6 @@ import java.util.Collection; import java.util.HashSet; -import java.util.Map; public class InventoryListener implements Listener { @@ -32,9 +30,9 @@ public class InventoryListener implements Listener { allowedActions.add(InventoryAction.SWAP_WITH_CURSOR); } - @EventHandler + @EventHandler(ignoreCancelled = true) public void onInventoryClick(InventoryClickEvent event) { - if (event.getSlot() < 0 + if (event.isCancelled() || event.getSlot() < 0 || !(event.getInventory().getHolder() instanceof Menu) || !(event.getWhoClicked() instanceof Player) || event.getClickedInventory() == null) return; @@ -54,36 +52,9 @@ public void onInventoryClick(InventoryClickEvent event) { } if (menu.isDraggable(player, event.getSlot())) { - ItemStack old = event.getCurrentItem(); - ItemStack cursor = event.getCursor(); - switch (event.getAction()) { - case PICKUP_ALL: - if (!menu.takeItem(player, event.getSlot(), 0)) - event.setCancelled(true); - break; - case PICKUP_HALF: - int amount = (int) Math.ceil((float) old.getAmount() / 2); - if (!menu.takeItem(player, event.getSlot(), amount)) - event.setCancelled(true); - break; - case PICKUP_ONE: - if (!menu.takeItem(player, event.getSlot(), 1)) - event.setCancelled(true); - break; - case PLACE_ALL: - case PLACE_SOME: - menu.placeItem(player, event.getSlot(), calcPlaced(old, cursor, 0)); - break; - case PLACE_ONE: - menu.placeItem(player, event.getSlot(), calcPlaced(old, cursor, 1)); - break; - case SWAP_WITH_CURSOR: - if (menu.takeItem(player, event.getSlot(), 0)) { - menu.placeItem(player, event.getSlot(), cursor); - } else { - event.setCancelled(true); - } + case PICKUP_ALL, PICKUP_HALF, PICKUP_ONE, SWAP_WITH_CURSOR: + if (menu.getPlacedItem(event.getSlot()).getType().isAir()) event.setCancelled(true); break; default: break; @@ -98,9 +69,9 @@ public void onInventoryClick(InventoryClickEvent event) { BukkitTasks.runForEntityLater(player, player::updateInventory, 2L); } - @EventHandler + @EventHandler(ignoreCancelled = true) public void onDrag(InventoryDragEvent event) { - if (event.getWhoClicked() instanceof Player + if (!event.isCancelled() && event.getWhoClicked() instanceof Player && event.getInventory().getHolder() instanceof Menu) { Player player = (Player) event.getWhoClicked(); @@ -112,7 +83,7 @@ public void onDrag(InventoryDragEvent event) { boolean hasMenuSlots = false; for (int slot : event.getRawSlots()) { - if (slot >= menu.getSize()) { + if (slot >= event.getView().getTopInventory().getSize()) { hasPlayerSlots = true; } else { hasMenuSlots = true; @@ -129,50 +100,33 @@ public void onDrag(InventoryDragEvent event) { event.setCancelled(true); return; } - - for (Map.Entry entry : event.getNewItems().entrySet()) { - menu.placeItem(player, entry.getKey(), entry.getValue()); - } } } - @EventHandler - public void onInventoryClose(InventoryCloseEvent event) { - if (event.getPlayer() instanceof Player && event.getInventory().getHolder() instanceof Menu) { - Player player = (Player) event.getPlayer(); - Menu closed = (Menu) event.getInventory().getHolder(); - Menu current = MenuManager.instance().getOpenedMenu(player); - - if (closed instanceof AbstractMenu abstractMenu) { - abstractMenu.dropPlaced(player); - } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void reconcileClick(InventoryClickEvent event) { + if (event.isCancelled() || !(event.getWhoClicked() instanceof Player player) + || !(event.getInventory().getHolder() instanceof AbstractMenu menu) + || event.getClickedInventory() != event.getInventory() + || !allowedActions.contains(event.getAction()) || !menu.isDraggable(player, event.getSlot())) return; + menu.queuePlacedReconcile(player, event.getSlot(), () -> !event.isCancelled()); + } - if (current != null && current.equals(closed)) { - // Pin to player's region: closeMenu calls player.closeInventory - // which on Folia must run on the player's region thread. - BukkitTasks.runForEntityLater(player, - () -> MenuManager.instance().closeMenu(player, false), - 3L - ); + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void reconcileDrag(InventoryDragEvent event) { + if (event.isCancelled() || !(event.getWhoClicked() instanceof Player player) + || !(event.getInventory().getHolder() instanceof AbstractMenu menu)) return; + for (int slot : event.getRawSlots()) { + if (slot < event.getInventory().getSize() && menu.isDraggable(player, slot)) { + menu.queuePlacedReconcile(player, slot, () -> !event.isCancelled()); } } } - private static ItemStack calcPlaced(ItemStack current, ItemStack cursor, int modifier) { - ItemStack stack; - int amount; - - if (current != null && current.getType() != Material.AIR) { - stack = current.clone(); - int mod = modifier > 0 ? modifier : cursor.getAmount(); - amount = stack.getAmount() + mod; - } else { - stack = cursor.clone(); - amount = modifier > 0 ? modifier : cursor.getAmount(); + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (event.getPlayer() instanceof Player player && event.getInventory().getHolder() instanceof Menu closed) { + MenuManager.instance().closeMenu(player, closed, false); } - - amount = Math.min(amount, stack.getMaxStackSize()); - stack.setAmount(amount); - return stack; } } diff --git a/plugin/src/main/java/ru/abstractmenus/menu/AbstractMenu.java b/plugin/src/main/java/ru/abstractmenus/menu/AbstractMenu.java index 7d05bc5..fac4dff 100644 --- a/plugin/src/main/java/ru/abstractmenus/menu/AbstractMenu.java +++ b/plugin/src/main/java/ru/abstractmenus/menu/AbstractMenu.java @@ -2,6 +2,7 @@ import lombok.Getter; import lombok.Setter; +import net.kyori.adventure.text.Component; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.entity.Player; @@ -15,6 +16,9 @@ import ru.abstractmenus.MainConfig; import ru.abstractmenus.api.AbstractMenusApi; import ru.abstractmenus.api.Rule; +import ru.abstractmenus.api.text.Colors; +import ru.abstractmenus.util.bukkit.BukkitTasks; +import java.util.function.BooleanSupplier; import ru.abstractmenus.datatype.TypeSlot; import ru.abstractmenus.menu.item.MenuItem; import ru.abstractmenus.data.Actions; @@ -98,6 +102,8 @@ private static long debounceFloorMs(ClickType type) { @Setter protected TypeSlot draggableSlots; protected Map placedItems = new ConcurrentHashMap<>(); + private Map pendingDeposits = new HashMap<>(); + private boolean closed; @Getter protected ItemStack lastPlaced = ItemUtil.empty(); @Getter @@ -137,9 +143,9 @@ public Item getItem(int slot) { @Override public void setItem(Slot slot, Item item, Player player) { slot.getSlots(index -> { + if (placedItems.containsKey(index)) return; showedItems.put(index, item); inventory.setItem(index, item.build(player, this)); - placedItems.remove(index); }); } @@ -152,6 +158,7 @@ public void removeMenuItem(int slot) { public void refreshItem(Slot slot, Player player) { if (inventory != null) { slot.getSlots((s) -> { + if (placedItems.containsKey(s)) return; Item item = getItem(s); if (item != null) { @@ -200,6 +207,7 @@ public ItemStack getPlacedItem(int slot) { } public void dropPlaced(Player player) { + reconcilePlacedItems(player); if (draggableSlots != null && !placedItems.isEmpty()) { Location loc = player.getEyeLocation(); for (ItemStack item : placedItems.values()) { @@ -211,6 +219,37 @@ public void dropPlaced(Player player) { } } + public void queuePlacedReconcile(Player player, int slot, BooleanSupplier accepted) { + pendingDeposits.merge(slot, accepted, (previous, next) -> () -> previous.getAsBoolean() || next.getAsBoolean()); + BukkitTasks.runForEntity(player, () -> reconcilePlacedItems(player)); + } + + public void reconcilePlacedItems(Player player) { + Map pending = new HashMap<>(pendingDeposits); + pendingDeposits.clear(); + for (var entry : pending.entrySet()) { + if (closed || !entry.getValue().getAsBoolean()) continue; + int slot = entry.getKey(); + ItemStack current = inventory.getItem(slot); + ItemStack previous = placedItems.get(slot); + if (current == null || current.getType().isAir()) { + takeItem(player, slot, 0); + } else if (current.isSimilar(previous) && current.getAmount() < previous.getAmount()) { + takeItem(player, slot, previous.getAmount() - current.getAmount()); + } else if (previous == null || !current.equals(previous)) { + if (previous != null && !current.isSimilar(previous)) takeItem(player, slot, 0); + placeItem(player, slot, current.clone()); + } + } + } + + protected void clearForRefresh(Player player) { + reconcilePlacedItems(player); + showedItems.clear(); + inventory.clear(); + placedItems.forEach((slot, stack) -> inventory.setItem(slot, stack)); + } + public void removePlacedItem(int slot, int amount) { ItemStack placed = placedItems.get(slot); @@ -296,7 +335,10 @@ public void setActivators(List activators) { @Override public void close(Player player, boolean closeInventory) { - if (closeInventory) + if (closed) return; + dropPlaced(player); + closed = true; + if (closeInventory && player.getOpenInventory().getTopInventory() == inventory) player.closeInventory(); if (closeActions != null) @@ -358,8 +400,8 @@ protected int getFreeSlot() { } protected void createInventory(Player player, InventoryHolder holder) { - String title = AbstractMenusApi.get().providers().placeholders().resolve().replace(player, this.title); - title = MiniMessageUtil.parseToLegacy(title); + String replacedTitle = AbstractMenusApi.get().providers().placeholders().resolve().replace(player, Colors.of(this.title)); + Component title = MiniMessageUtil.parseToComponent(replacedTitle); if (this.type != null) { this.inventory = Bukkit.createInventory(holder, type, title); @@ -374,6 +416,8 @@ public AbstractMenu clone() { AbstractMenu menu = (AbstractMenu) super.clone(); menu.showedItems = new HashMap<>(); menu.placedItems = new ConcurrentHashMap<>(); + menu.pendingDeposits = new HashMap<>(); + menu.closed = false; menu.slotClickExpiry = new HashMap<>(); return menu; } catch (CloneNotSupportedException e) { diff --git a/plugin/src/main/java/ru/abstractmenus/menu/SimpleMenu.java b/plugin/src/main/java/ru/abstractmenus/menu/SimpleMenu.java index 69002a7..a75c81b 100644 --- a/plugin/src/main/java/ru/abstractmenus/menu/SimpleMenu.java +++ b/plugin/src/main/java/ru/abstractmenus/menu/SimpleMenu.java @@ -58,8 +58,7 @@ public boolean open(Player player) { @Override public void refresh(Player player) { - showedItems.clear(); - inventory.clear(); + clearForRefresh(player); placeItems(player); } @@ -85,7 +84,7 @@ protected void placeItems(Player player) { } slot.getSlots(s -> { - if (s >= 0 && s < inventory.getSize()) { + if (s >= 0 && s < inventory.getSize() && !placedItems.containsKey(s)) { showedItems.put(s, item); inventory.setItem(s, built); } diff --git a/plugin/src/main/java/ru/abstractmenus/menu/animated/AnimatedMenu.java b/plugin/src/main/java/ru/abstractmenus/menu/animated/AnimatedMenu.java index edc8745..adab813 100644 --- a/plugin/src/main/java/ru/abstractmenus/menu/animated/AnimatedMenu.java +++ b/plugin/src/main/java/ru/abstractmenus/menu/animated/AnimatedMenu.java @@ -35,10 +35,10 @@ public void refresh(Player player) { animStartActions.activate(player, this, null); currentFrame = 0; + endActionsActivated = false; lastPlayTime = TimeUtil.currentTimeTicks(); - showedItems.clear(); - inventory.clear(); + clearForRefresh(player); placeItems(player); } @@ -72,14 +72,14 @@ public void update(Player player) { updateActions.activate(player, this, null); if (frame.isClear()) { - showedItems.clear(); - inventory.clear(); + clearForRefresh(player); placeItems(player); } int size = inventory.getSize(); for (Map.Entry entry : played.entrySet()) { int slot = entry.getKey(); + if (placedItems.containsKey(slot)) continue; Frame.PlayedSlot ps = entry.getValue(); showedItems.put(slot, ps.item()); if (slot >= 0 && slot < size) { diff --git a/plugin/src/main/java/ru/abstractmenus/menu/generated/GeneratedMenu.java b/plugin/src/main/java/ru/abstractmenus/menu/generated/GeneratedMenu.java index 4e5cba0..91e1e62 100644 --- a/plugin/src/main/java/ru/abstractmenus/menu/generated/GeneratedMenu.java +++ b/plugin/src/main/java/ru/abstractmenus/menu/generated/GeneratedMenu.java @@ -21,9 +21,9 @@ public class GeneratedMenu extends SimpleMenu { @Setter private Matrix matrix; - private final Map slotContexts = new HashMap<>(); + private Map slotContexts = new HashMap<>(); - private ArrayList snapshot; + private ArrayList snapshot = new ArrayList<>(); @Getter private Object currentObject; private int perPage; @@ -54,7 +54,7 @@ private boolean changePage(int value) { int current = page; page = page + value; page = Math.max(0, page); - page = Math.min(page, pages - 1); + page = Math.min(page, Math.max(0, pages - 1)); return current != page; } @@ -89,8 +89,6 @@ public boolean open(Player player) { @Override public void refresh(Player player) { - super.refresh(player); - slotContexts.clear(); snapshot = new ArrayList<>(catalog.snapshot(player, this)); @@ -99,6 +97,8 @@ public void refresh(Player player) { pages = snapshot.size() / perPage; if (snapshot.size() % perPage > 0) pages++; + page = Math.max(0, Math.min(page, Math.max(0, pages - 1))); + super.refresh(player); int from = page * perPage; @@ -133,10 +133,13 @@ public void click(int slot, Player player, ClickType type) { @Override public GeneratedMenu clone() { - return (GeneratedMenu) super.clone(); + GeneratedMenu menu = (GeneratedMenu) super.clone(); + if (menu != null) menu.slotContexts = new HashMap<>(); + return menu; } private void setItem(int slot, Player player, Item item, Object ctx) { + if (placedItems.containsKey(slot)) return; ItemStack stack = item.build(player, this); slotContexts.put(slot, ctx); diff --git a/plugin/src/main/java/ru/abstractmenus/nms/title/Title.java b/plugin/src/main/java/ru/abstractmenus/nms/title/Title.java index 642b296..eb7e6ce 100644 --- a/plugin/src/main/java/ru/abstractmenus/nms/title/Title.java +++ b/plugin/src/main/java/ru/abstractmenus/nms/title/Title.java @@ -14,8 +14,8 @@ public class Title { private String title; private String subtitle; private int fadeIn; - private int fadeOut; private int stay; + private int fadeOut; public void send(Player player) { sender.send(player, this); diff --git a/plugin/src/main/java/ru/abstractmenus/placeholders/hooks/VarPlaceholders.java b/plugin/src/main/java/ru/abstractmenus/placeholders/hooks/VarPlaceholders.java index 7b4ff45..ecac2ab 100644 --- a/plugin/src/main/java/ru/abstractmenus/placeholders/hooks/VarPlaceholders.java +++ b/plugin/src/main/java/ru/abstractmenus/placeholders/hooks/VarPlaceholders.java @@ -12,10 +12,7 @@ public static class VarHook implements PlaceholderHook { @Override public String replace(String placeholder, Player player) { - int colonIdx = placeholder.indexOf(':'); - if (colonIdx == -1 || colonIdx >= placeholder.length() - 1) return null; - - String rest = placeholder.substring(colonIdx + 1); + String rest = parameter(placeholder); String defaultVal = ""; int secondColon = rest.indexOf(':'); if (secondColon != -1) { @@ -43,10 +40,7 @@ public static class VarPlayerHook implements PlaceholderHook { public String replace(String placeholder, Player player) { if (player == null) return null; - int colonIdx = placeholder.indexOf(':'); - if (colonIdx == -1 || colonIdx >= placeholder.length() - 1) return null; - - String varName = placeholder.substring(colonIdx + 1); + String varName = parameter(placeholder); String defaultVal = ""; int secondColon = varName.indexOf(':'); if (secondColon != -1) { @@ -64,11 +58,9 @@ public static class VarTempHook implements PlaceholderHook { @Override public String replace(String placeholder, Player player) { - int colonIdx = placeholder.indexOf(':'); - if (colonIdx == -1 || colonIdx >= placeholder.length() - 1) return null; - - return getVarTime(VariableManagerImpl.instance().getGlobal( - placeholder.substring(colonIdx + 1))); + String[] parts = parameter(placeholder).split(":", 2); + Var var = VariableManagerImpl.instance().getGlobal(parts[0]); + return var == null && parts.length > 1 ? parts[1] : getVarTime(var); } } @@ -78,14 +70,18 @@ public static class VarTempPlayerHook implements PlaceholderHook { public String replace(String placeholder, Player player) { if (player == null) return null; - int colonIdx = placeholder.indexOf(':'); - if (colonIdx == -1 || colonIdx >= placeholder.length() - 1) return null; - - return getVarTime(VariableManagerImpl.instance().getPersonal( - player.getName(), placeholder.substring(colonIdx + 1))); + String[] parts = parameter(placeholder).split(":", 2); + Var var = VariableManagerImpl.instance().getPersonal(player.getName(), parts[0]); + return var == null && parts.length > 1 ? parts[1] : getVarTime(var); } } + private static String parameter(String placeholder) { + if (placeholder.startsWith(":")) return placeholder.substring(1); + if (placeholder.startsWith("get:")) return placeholder.substring(4); + return placeholder; + } + private static String getVarTime(Var var) { return TimeUtil.getTimeString((var != null) ? var.expiry() - System.currentTimeMillis() : 0L); } diff --git a/plugin/src/main/java/ru/abstractmenus/serializers/BannerPatternSerializer.java b/plugin/src/main/java/ru/abstractmenus/serializers/BannerPatternSerializer.java index fc06528..94115ea 100644 --- a/plugin/src/main/java/ru/abstractmenus/serializers/BannerPatternSerializer.java +++ b/plugin/src/main/java/ru/abstractmenus/serializers/BannerPatternSerializer.java @@ -1,12 +1,17 @@ package ru.abstractmenus.serializers; -import ru.abstractmenus.hocon.api.ConfigNode; -import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; -import ru.abstractmenus.hocon.api.serialize.NodeSerializer; +import io.papermc.paper.registry.RegistryAccess; +import io.papermc.paper.registry.RegistryKey; import org.bukkit.DyeColor; +import org.bukkit.NamespacedKey; import org.bukkit.block.banner.Pattern; import org.bukkit.block.banner.PatternType; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; +import ru.abstractmenus.hocon.api.serialize.NodeSerializer; + +import java.util.Locale; public class BannerPatternSerializer implements NodeSerializer { @@ -18,9 +23,9 @@ public Pattern deserialize(Class token, ConfigNode node) throws NodeSer PatternType type; DyeColor color; - try { - type = PatternType.valueOf(typeStr); - } catch (IllegalArgumentException e) { + NamespacedKey key = NamespacedKey.fromString(typeStr.toLowerCase(Locale.ROOT)); + type = key == null ? null : RegistryAccess.registryAccess().getRegistry(RegistryKey.BANNER_PATTERN).get(key); + if (type == null) { throw new NodeSerializeException(node.node("type"), "Cannot find banner pattern type: " + typeStr); } diff --git a/plugin/src/main/java/ru/abstractmenus/services/MenuManager.java b/plugin/src/main/java/ru/abstractmenus/services/MenuManager.java index a53d4e8..e7566df 100644 --- a/plugin/src/main/java/ru/abstractmenus/services/MenuManager.java +++ b/plugin/src/main/java/ru/abstractmenus/services/MenuManager.java @@ -22,7 +22,6 @@ import ru.abstractmenus.menu.AbstractMenu; import ru.abstractmenus.util.FileUtils; import ru.abstractmenus.api.Logger; -import ru.abstractmenus.util.NMS; import ru.abstractmenus.util.bukkit.Skulls; import ru.abstractmenus.util.bukkit.BukkitTasks; import ru.abstractmenus.util.bukkit.TaskHandle; @@ -51,6 +50,7 @@ private record OpenedMenu(Menu menu, Player player) {} private final Path menuFolder; private final Map menus = new ConcurrentHashMap<>(); + private final Map> fileMenus = new HashMap<>(); private final Map openedMenus = new ConcurrentHashMap<>(); private final Map inputActions = new ConcurrentHashMap<>(); @@ -81,7 +81,8 @@ public Menu getMenu(String name) { public void addMenu(String name, Menu menu) { if (menu != null) { - menus.put(name.toLowerCase(), menu); + Menu previous = menus.put(name.toLowerCase(), menu); + if (previous != null && previous != menu) unregisterActivators(previous); } } @@ -133,6 +134,11 @@ public void closeMenu(Player player, boolean closeInventory) { } } + public void closeMenu(Player player, Menu menu, boolean closeInventory) { + openedMenus.computeIfPresent(player.getUniqueId(), (id, entry) -> entry.menu() == menu ? null : entry); + menu.close(player, closeInventory); + } + public void refreshMenu(Player player) { Menu opened = getOpenedMenu(player); @@ -220,6 +226,9 @@ private int loadFile(Path file) { return 0; } + removeFileMenus(file); + Map loaded = new HashMap<>(); + fileMenus.put(file.toAbsolutePath().normalize(), loaded); ConfigNode menuList = conf.node("menus"); if (!menuList.isNull()) { @@ -229,6 +238,7 @@ private int loadFile(Path file) { try { Menu menu = entry.getValue().getValue(Menu.class); addMenu(entry.getKey(), menu); + loaded.put(entry.getKey().toLowerCase(), menu); count++; } catch (NodeSerializeException ne) { if (ne.getCauseNode() != null) { @@ -246,6 +256,7 @@ private int loadFile(Path file) { try { Menu menu = conf.getValue(Menu.class); addMenu(filename, menu); + loaded.put(filename.toLowerCase(), menu); count++; } catch (NodeSerializeException ne) { if (ne.getCauseNode() != null) { @@ -269,7 +280,7 @@ public boolean serve() throws IOException { } else { watcher = FileSystems.getDefault().newWatchService(); menuFolder.register(watcher, StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_MODIFY); + StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); watcherThread = new Thread(() -> { while (true) { WatchKey key; @@ -289,7 +300,9 @@ public boolean serve() throws IOException { Path filename = ev.context(); Path file = menuFolder.resolve(filename); - if (Files.isRegularFile(file) && System.currentTimeMillis() > lastUpdated + 100) { + if (kind == StandardWatchEventKinds.ENTRY_DELETE) { + BukkitTasks.runTask(() -> removeFileMenus(file)); + } else if (Files.isRegularFile(file) && System.currentTimeMillis() > lastUpdated + 100) { Logger.info("Detected changes in " + filename + ". Loading ..."); // We are on the WatchService thread - hop back into the // server scheduler before parsing. On Folia BukkitTasks.runTask @@ -331,38 +344,51 @@ private void stopWatcher() { public void unloadAll() { stopWatcher(); unregisterActivators(); - Bukkit.getOnlinePlayers().forEach(this::closeMenu); + for (var entry : openedMenus.entrySet()) { + OpenedMenu opened = entry.getValue(); + if (openedMenus.remove(entry.getKey(), opened)) { + if (!plugin.isEnabled() || Bukkit.isOwnedByCurrentRegion(opened.player())) { + opened.menu().close(opened.player(), true); + } else { + BukkitTasks.runForEntity(opened.player(), () -> opened.menu().close(opened.player(), true)); + } + } + } stopUpdateTask(); menus.clear(); - openedMenus.clear(); + fileMenus.clear(); inputActions.clear(); Skulls.clearCache(); } - private void unregisterActivators() { - for (Menu menu : menus.values()) { - List activators = menu.getActivators(); - - if (activators != null) { - for (Activator activator : activators) { - if (activator instanceof OpenCommand) { - Command cmd = ((OpenCommand) activator).getCommand(); + private void removeFileMenus(Path file) { + Map owned = fileMenus.remove(file.toAbsolutePath().normalize()); + if (owned == null) return; + owned.forEach((name, menu) -> { + if (menus.remove(name, menu)) unregisterActivators(menu); + }); + } - AbstractMenus.instance() - .getCommandManager() - .unregister(cmd); - } + private void unregisterActivators() { + for (Menu menu : menus.values()) unregisterActivators(menu); + } - HandlerList.unregisterAll(activator); + private void unregisterActivators(Menu menu) { + List activators = menu.getActivators(); + if (activators == null) return; + for (Activator activator : activators) { + if (activator instanceof OpenCommand command) { + var manager = AbstractMenus.instance().getCommandManager(); + if (manager.get(command.getCommand().getName()) == command.getCommand()) { + manager.unregister(command.getCommand()); } } + HandlerList.unregisterAll(activator); } } private void loadExampleMenus() throws Exception { if (!Files.exists(menuFolder)) { - int version = NMS.getMinorVersion(); - Files.createDirectory(menuFolder); ConfigurationLoader.builder() @@ -372,18 +398,8 @@ private void loadExampleMenus() throws Exception { .build() .load(); - String animMenuPath; - - if (version >= 21) { - animMenuPath = "/1_21/menu_anim.conf"; - } else if (version >= 13) { - animMenuPath = "/1_13/menu_anim.conf"; - } else { - animMenuPath = "/menu_anim.conf"; - } - ConfigurationLoader.builder() - .source(ConfigSources.resource(animMenuPath, plugin) + .source(ConfigSources.resource("/menu_anim.conf", plugin) .copyTo(menuFolder)) .serializers(AbstractMenusApi.get().serializers()) .build() @@ -452,4 +468,4 @@ public void run() { } } -} \ No newline at end of file +} diff --git a/plugin/src/main/java/ru/abstractmenus/util/LegacyColorTagReplacer.java b/plugin/src/main/java/ru/abstractmenus/util/LegacyColorTagReplacer.java index 43dd075..72e0f16 100644 --- a/plugin/src/main/java/ru/abstractmenus/util/LegacyColorTagReplacer.java +++ b/plugin/src/main/java/ru/abstractmenus/util/LegacyColorTagReplacer.java @@ -1,6 +1,7 @@ package ru.abstractmenus.util; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -25,7 +26,7 @@ final class LegacyColorTagReplacer { static final char SECTION_CHAR = '\u00a7'; - private static final Pattern COLOR_PATTERN = Pattern.compile(SECTION_CHAR + "([0-9a-fk-rx])"); + private static final Pattern COLOR_PATTERN = Pattern.compile(SECTION_CHAR + "x(?:" + SECTION_CHAR + "[0-9a-f]){6}|" + SECTION_CHAR + "#([0-9a-f]{6})|" + SECTION_CHAR + "([0-9a-fk-rx])", Pattern.CASE_INSENSITIVE); private static final Map COLOR_TAGS = new HashMap<>(); static { @@ -67,7 +68,10 @@ static String replaceLegacyTags(String input) { StringBuilder sb = new StringBuilder(input.length() + 32); do { - String replacement = COLOR_TAGS.get(matcher.group(1)); + String code = matcher.group(2); + String replacement = code != null + ? COLOR_TAGS.get(code.toLowerCase(Locale.ROOT)) + : "<#" + matcher.group().replace(String.valueOf(SECTION_CHAR), "").substring(1) + ">"; // Replacement values contain '<' and '>' but neither '$' nor '\\', // so they are safe as appendReplacement literals without quoting. matcher.appendReplacement(sb, replacement != null ? replacement : matcher.group()); diff --git a/plugin/src/main/java/ru/abstractmenus/util/MiniMessageUtil.java b/plugin/src/main/java/ru/abstractmenus/util/MiniMessageUtil.java index 67e51aa..683120d 100644 --- a/plugin/src/main/java/ru/abstractmenus/util/MiniMessageUtil.java +++ b/plugin/src/main/java/ru/abstractmenus/util/MiniMessageUtil.java @@ -44,6 +44,10 @@ public static List parseToLegacy(List input) { return replacer.parseToLegacy(input); } + public static Component parseToComponent(String input) { + return input == null ? null : replacer.parseToComponent(input); + } + private interface Replacer { void sendParsed(List input, Player player); @@ -52,6 +56,8 @@ private interface Replacer { List parseToLegacy(List input); + Component parseToComponent(String input); + } private static class ActiveReplacer implements Replacer { @@ -69,7 +75,7 @@ public void sendParsed(List input, Player player) { for (String line : input) { if (line != null) { - send(parse(LegacyColorTagReplacer.replaceLegacyTags(line)), player); + send(parseToComponent(line), player); } } } @@ -79,7 +85,7 @@ public String parseToLegacy(String input) { if (input == null) return null; if (input.isEmpty()) return input; - return serializer.serialize(parse(LegacyColorTagReplacer.replaceLegacyTags(input))); + return serializer.serialize(parseToComponent(input)); } @Override @@ -98,8 +104,9 @@ private void send(Component component, Player player) { player.sendMessage(component); } - private Component parse(String input) { - return miniMessage.deserialize(input); + @Override + public Component parseToComponent(String input) { + return miniMessage.deserialize(LegacyColorTagReplacer.replaceLegacyTags(input)); } } @@ -119,6 +126,11 @@ public String parseToLegacy(String input) { public List parseToLegacy(List input) { return input; } + + @Override + public Component parseToComponent(String input) { + return LegacyComponentSerializer.legacySection().deserialize(input); + } } } diff --git a/plugin/src/main/java/ru/abstractmenus/util/NMS.java b/plugin/src/main/java/ru/abstractmenus/util/NMS.java deleted file mode 100644 index b7bfcea..0000000 --- a/plugin/src/main/java/ru/abstractmenus/util/NMS.java +++ /dev/null @@ -1,30 +0,0 @@ -package ru.abstractmenus.util; - -import org.bukkit.Bukkit; - -public final class NMS { - - private static volatile int minorVersion = -1; - - private NMS() {} - - public static String getVersion() { - try { - // Paper now has API to get version string in format .. - // Replace `.` to `_` for backward compatibility - return Bukkit.getMinecraftVersion().replace('.', '_'); - } catch (Throwable t) { - return Bukkit.getServer().getClass().getPackage().getName().split("\\.")[3]; - } - } - - public static int getMinorVersion() { - if (minorVersion == -1) { - String ver = getVersion(); - String[] arr = ver.split("_"); - minorVersion = arr.length > 1 ? Integer.parseInt(arr[1]) : -1; - } - - return minorVersion; - } -} diff --git a/plugin/src/main/java/ru/abstractmenus/util/bukkit/Skulls.java b/plugin/src/main/java/ru/abstractmenus/util/bukkit/Skulls.java index 9fbec18..8995876 100644 --- a/plugin/src/main/java/ru/abstractmenus/util/bukkit/Skulls.java +++ b/plugin/src/main/java/ru/abstractmenus/util/bukkit/Skulls.java @@ -6,22 +6,19 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.SkullMeta; -import ru.abstractmenus.api.Logger; import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; public final class Skulls { + private static final Pattern PLAYER_NAME = Pattern.compile("[!-~]{1,16}"); + private static final Map skullCache = new ConcurrentHashMap<>(); - /** - * Cache of resolved player-skin skulls keyed by lowercased player name. - * Populated lazily by {@link #getPlayerSkull(String)} and invalidated by - * {@link #invalidatePlayerSkull(String)} (called from PlayerJoinEvent so a - * rejoining player gets fresh skin data on the next query). - */ + // PlayerJoinEvent invalidates cached heads so online skin changes are picked up. private static final Map playerSkullCache = new ConcurrentHashMap<>(); private Skulls() { @@ -71,24 +68,16 @@ public static ItemStack getCustomSkull(com.destroystokyo.paper.profile.PlayerPro } public static ItemStack getPlayerSkull(String playerName) { - if (playerName == null) return null; + if (playerName == null || !PLAYER_NAME.matcher(playerName).matches()) return null; String key = playerName.toLowerCase(Locale.ROOT); ItemStack cached = playerSkullCache.get(key); if (cached != null) return cached.clone(); - Player player = Bukkit.getPlayer(playerName); - - if (player == null) { - Logger.info("Player '" + playerName + "' is not online or not found."); - return null; - } - - PlayerProfile profile = player.getPlayerProfile(); - - if (profile == null) { - Logger.info("PlayerProfile for '" + playerName + "' not found."); - return null; + Player player = Bukkit.getPlayerExact(playerName); + PlayerProfile profile = player != null ? player.getPlayerProfile() : null; + if (profile == null || !profile.hasTextures()) { + profile = Bukkit.createProfileExact(null, playerName); } ItemStack result = getCustomSkull(profile); diff --git a/plugin/src/main/java/ru/abstractmenus/util/proxy/ClassInfo.java b/plugin/src/main/java/ru/abstractmenus/util/proxy/ClassInfo.java deleted file mode 100644 index b97b264..0000000 --- a/plugin/src/main/java/ru/abstractmenus/util/proxy/ClassInfo.java +++ /dev/null @@ -1,30 +0,0 @@ -package ru.abstractmenus.util.proxy; - -import lombok.Data; - -/** - * From SkinRestorer - */ -@Data -public class ClassInfo { - private static final ClassInfo INSTANCE = new ClassInfo(); - private final boolean craftBukkit; - private final boolean spigot; - private final boolean paper; - private final boolean folia; - private final boolean bungeecord; - private final boolean velocity; - - private ClassInfo() { - spigot = ReflectionUtil.classExists("org.spigotmc.SpigotConfig"); - paper = ReflectionUtil.classExists("com.destroystokyo.paper.PaperConfig", "io.papermc.paper.configuration.Configuration"); - craftBukkit = ReflectionUtil.classExists("org.bukkit.Bukkit"); - folia = ReflectionUtil.classExists("io.papermc.paper.threadedregions.RegionizedServerInitEvent"); - bungeecord = ReflectionUtil.classExists("net.md_5.bungee.BungeeCord"); - velocity = ReflectionUtil.classExists("com.velocitypowered.proxy.Velocity"); - } - - public static ClassInfo get() { - return INSTANCE; - } -} \ No newline at end of file diff --git a/plugin/src/main/java/ru/abstractmenus/util/proxy/ReflectionUtil.java b/plugin/src/main/java/ru/abstractmenus/util/proxy/ReflectionUtil.java deleted file mode 100644 index 112ef3e..0000000 --- a/plugin/src/main/java/ru/abstractmenus/util/proxy/ReflectionUtil.java +++ /dev/null @@ -1,30 +0,0 @@ -package ru.abstractmenus.util.proxy; - -/** - * From SkinRestorer - */ -public class ReflectionUtil { - - private ReflectionUtil() { - } - - public static boolean classExists(String clazz) { - try { - Class.forName(clazz); - return true; - } catch (ClassNotFoundException e) { - return false; - } - } - - public static boolean classExists(String... classNames) { - for (String className : classNames) { - if (classExists(className)) { - return true; - } - } - - return false; - } - -} \ No newline at end of file diff --git a/plugin/src/main/java/ru/abstractmenus/variables/VariableManagerImpl.java b/plugin/src/main/java/ru/abstractmenus/variables/VariableManagerImpl.java index 8d54d9e..00f2643 100644 --- a/plugin/src/main/java/ru/abstractmenus/variables/VariableManagerImpl.java +++ b/plugin/src/main/java/ru/abstractmenus/variables/VariableManagerImpl.java @@ -26,6 +26,7 @@ public final class VariableManagerImpl implements VariableManager { private final Map cache = new ConcurrentHashMap<>(); private TaskHandle timerTask; + private boolean shuttingDown; public VariableManagerImpl(MainConfig conf) { instance = this; @@ -79,7 +80,8 @@ public VarBuilder createBuilder() { return new VarBuilderImpl(); } - public void shutdown() { + public synchronized void shutdown() { + shuttingDown = true; stopTimer(); dao.close(); } @@ -92,7 +94,7 @@ public void modifyNumericPersonal(String username, String name, Function func) { + private synchronized void modifyNumeric(String key, Function func) { Var variable = cache.get(key); String name; double value; @@ -111,6 +113,7 @@ private void modifyNumeric(String key, Function func) { variable = createBuilder() .name(name) + .expiry(variable == null ? 0 : variable.expiry()) .value(String.valueOf(NumberUtil.round(value, 100))) .build(); @@ -121,8 +124,8 @@ public void cache(String key, Var var) { cache.put(key, var); } - private void save(String key, Var var, boolean replace) { - if (var.isExpired()) return; + private synchronized void save(String key, Var var, boolean replace) { + if (shuttingDown || var.isExpired()) return; Var cached = cache.get(key); @@ -137,7 +140,8 @@ private void save(String key, Var var, boolean replace) { sync("save", key, var); } - public void delete(String key) { + public synchronized void delete(String key) { + if (shuttingDown) return; cache.remove(key); dao.delete(key); sync("delete", key, null); @@ -178,7 +182,8 @@ private void stopTimer() { } } - private void timerTask() { + private synchronized void timerTask() { + if (shuttingDown) return; boolean removed = cache.entrySet() .removeIf(entry -> entry.getValue().isExpired()); diff --git a/plugin/src/main/java/ru/abstractmenus/variables/VariablesDao.java b/plugin/src/main/java/ru/abstractmenus/variables/VariablesDao.java index ad70912..bab97e2 100644 --- a/plugin/src/main/java/ru/abstractmenus/variables/VariablesDao.java +++ b/plugin/src/main/java/ru/abstractmenus/variables/VariablesDao.java @@ -4,18 +4,25 @@ import ru.abstractmenus.api.variables.Var; import ru.abstractmenus.api.variables.VariableManager; import ru.abstractmenus.util.FileUtils; -import ru.abstractmenus.util.bukkit.BukkitTasks; import java.nio.file.Path; import java.nio.file.Paths; import java.sql.*; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; public class VariablesDao { private final VariableManager varManager; private final Object connectionLock = new Object(); private Connection connection; + private final ExecutorService writer = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "AbstractMenus-VariablesWriter"); + thread.setDaemon(true); + return thread; + }); public VariablesDao(VariableManager varManager) { this.varManager = varManager; @@ -67,8 +74,9 @@ public void cacheAll(Map cache) { } } - private void executeUpdate(String sql, Object... values) { - BukkitTasks.runTaskAsync(() -> { + private synchronized void executeUpdate(String sql, Object... values) { + if (writer.isShutdown()) return; + writer.execute(() -> { synchronized (connectionLock) { try (PreparedStatement statement = connection.prepareStatement(sql)) { for (int i = 0; i < values.length; i++) { @@ -117,11 +125,26 @@ void init(Path folder) { } void close() { + synchronized (this) { + if (!writer.isShutdown()) { + writer.execute(() -> { + synchronized (connectionLock) { + try { + if (connection != null) connection.close(); + } catch (SQLException e) { + Logger.warning("Cannot close variables database: " + e.getMessage()); + } + } + }); + writer.shutdown(); + } + } try { - if (connection != null && !connection.isClosed()) { - connection.close(); + if (!writer.awaitTermination(5, TimeUnit.SECONDS)) { + Logger.warning("Variables writer is still draining; the database will close after pending writes"); } - } catch (Throwable ignore) { + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } } } diff --git a/plugin/src/main/resources/1_13/menu_anim.conf b/plugin/src/main/resources/1_13/menu_anim.conf deleted file mode 100644 index 6029bc5..0000000 --- a/plugin/src/main/resources/1_13/menu_anim.conf +++ /dev/null @@ -1,186 +0,0 @@ -# -# Menu example with simple animation -# Here is used a templates system. We recommend you to check the docs to understand this code -# - -title: "&eRoulette" -size: 3 - -activators { - command: "anim" -} - -# Looping this animation -loop: true - -# A frames of animation -frames: [ - { - delay: 1 - clear: false - items: [ - ${wood} { slot: 10 }, - ${iron} { slot: 11 }, - ${beef} { slot: 12 }, - ${stone} { slot: 13 }, - ${diamond} { slot: 14 }, - ${grass} { slot: 15 }, - ${leggings} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${leggings} { slot: 10 }, - ${wood} { slot: 11 }, - ${iron} { slot: 12 }, - ${beef} { slot: 13 }, - ${stone} { slot: 14 }, - ${diamond} { slot: 15 }, - ${grass} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${grass} { slot: 10 }, - ${leggings} { slot: 11 }, - ${wood} { slot: 12 }, - ${iron} { slot: 13 }, - ${beef} { slot: 14 }, - ${stone} { slot: 15 }, - ${diamond} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${diamond} { slot: 10 }, - ${grass} { slot: 11 }, - ${leggings} { slot: 12 }, - ${wood} { slot: 13 }, - ${iron} { slot: 14 }, - ${beef} { slot: 15 }, - ${stone} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${stone} { slot: 10 }, - ${diamond} { slot: 11 }, - ${grass} { slot: 12 }, - ${leggings} { slot: 13 }, - ${wood} { slot: 14 }, - ${iron} { slot: 15 }, - ${beef} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${beef} { slot: 10 }, - ${stone} { slot: 11 }, - ${diamond} { slot: 12 }, - ${grass} { slot: 13 }, - ${leggings} { slot: 14 }, - ${wood} { slot: 15 }, - ${iron} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${iron} { slot: 10 }, - ${beef} { slot: 11 }, - ${stone} { slot: 12 }, - ${diamond} { slot: 13 }, - ${grass} { slot: 14 }, - ${leggings} { slot: 15 }, - ${wood} { slot: 16 } - ] - } -] - -# Static background (white stained glass pane) -items: [ - ${whitePane} { slot: "0-9" }, - ${whitePane} { slot: "17-26" } -] - -# A templates for some roulette items - -whitePane { - material: WHITE_STAINED_GLASS_PANE - name: " " -} - -wood { - material: OAK_WOOD - name: " " - click { - itemAdd { - material: OAK_WOOD - } - } -} - -beef { - material: BEEF - click { - itemAdd { - material: BEEF - } - } -} - -stone{ - material: STONE - click { - itemAdd { - material: STONE - } - } -} - -grass { - material: GRASS - click { - itemAdd { - material: GRASS - } - } -} - -iron { - material: IRON_INGOT - click { - itemAdd { - material: IRON_INGOT - } - } -} - -leggings { - material: LEATHER_LEGGINGS - click { - itemAdd { - material: LEATHER_LEGGINGS - } - } -} - -diamond { - material: DIAMOND - click { - itemAdd { - material: DIAMOND - } - } -} \ No newline at end of file diff --git a/plugin/src/main/resources/1_21/menu_anim.conf b/plugin/src/main/resources/1_21/menu_anim.conf deleted file mode 100644 index 83019ac..0000000 --- a/plugin/src/main/resources/1_21/menu_anim.conf +++ /dev/null @@ -1,186 +0,0 @@ -# -# Menu example with simple animation -# Here is used a templates system. We recommend you to check the docs to understand this code -# - -title: "&eRoulette" -size: 3 - -activators { - command: "anim" -} - -# Looping this animation -loop: true - -# A frames of animation -frames: [ - { - delay: 1 - clear: false - items: [ - ${wood} { slot: 10 }, - ${iron} { slot: 11 }, - ${beef} { slot: 12 }, - ${stone} { slot: 13 }, - ${diamond} { slot: 14 }, - ${grass} { slot: 15 }, - ${leggings} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${leggings} { slot: 10 }, - ${wood} { slot: 11 }, - ${iron} { slot: 12 }, - ${beef} { slot: 13 }, - ${stone} { slot: 14 }, - ${diamond} { slot: 15 }, - ${grass} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${grass} { slot: 10 }, - ${leggings} { slot: 11 }, - ${wood} { slot: 12 }, - ${iron} { slot: 13 }, - ${beef} { slot: 14 }, - ${stone} { slot: 15 }, - ${diamond} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${diamond} { slot: 10 }, - ${grass} { slot: 11 }, - ${leggings} { slot: 12 }, - ${wood} { slot: 13 }, - ${iron} { slot: 14 }, - ${beef} { slot: 15 }, - ${stone} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${stone} { slot: 10 }, - ${diamond} { slot: 11 }, - ${grass} { slot: 12 }, - ${leggings} { slot: 13 }, - ${wood} { slot: 14 }, - ${iron} { slot: 15 }, - ${beef} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${beef} { slot: 10 }, - ${stone} { slot: 11 }, - ${diamond} { slot: 12 }, - ${grass} { slot: 13 }, - ${leggings} { slot: 14 }, - ${wood} { slot: 15 }, - ${iron} { slot: 16 } - ] - }, - { - delay: 1 - clear: false - items: [ - ${iron} { slot: 10 }, - ${beef} { slot: 11 }, - ${stone} { slot: 12 }, - ${diamond} { slot: 13 }, - ${grass} { slot: 14 }, - ${leggings} { slot: 15 }, - ${wood} { slot: 16 } - ] - } -] - -# Static background (white stained glass pane) -items: [ - ${whitePane} { slot: "0-9" }, - ${whitePane} { slot: "17-26" } -] - -# A templates for some roulette items - -whitePane { - material: WHITE_STAINED_GLASS_PANE - name: " " -} - -wood { - material: OAK_WOOD - name: " " - click { - itemAdd { - material: OAK_WOOD - } - } -} - -beef { - material: BEEF - click { - itemAdd { - material: BEEF - } - } -} - -stone{ - material: STONE - click { - itemAdd { - material: STONE - } - } -} - -grass { - material: GRASS_BLOCK - click { - itemAdd { - material: GRASS_BLOCK - } - } -} - -iron { - material: IRON_INGOT - click { - itemAdd { - material: IRON_INGOT - } - } -} - -leggings { - material: LEATHER_LEGGINGS - click { - itemAdd { - material: LEATHER_LEGGINGS - } - } -} - -diamond { - material: DIAMOND - click { - itemAdd { - material: DIAMOND - } - } -} \ No newline at end of file diff --git a/plugin/src/main/resources/menu.conf b/plugin/src/main/resources/menu.conf index 271f239..0787ca4 100644 --- a/plugin/src/main/resources/menu.conf +++ b/plugin/src/main/resources/menu.conf @@ -4,10 +4,27 @@ activators { command: "menu" } items: [ + { + slot: 3 + material: DIAMOND_SWORD + name: "&b&lDisplay sword" + count: 64 + glow: true + flags: HIDE_ATTRIBUTES + }, { slot: 4 material: CAKE name: "&6&lClick me" + itemModel: "minecraft:cake" + maxStackSize: 99 + rarity: RARE + model { + floats: [1.0, 2.5] + flags: [true, false] + strings: ["cake"] + colors: ["#ff8000"] + } lore: "&aI really want you click me" click { message: "&aHi! Thanks for the click. Hold your cake." @@ -22,10 +39,10 @@ items: [ slot: 5 material: BUNDLE name: "&6&lBundle" + model: 123 tooltipDisplay { - hiddenComponents: [ - BUNDLE_CONTENTS - ] + hideTooltip: false + hiddenComponents: [BUNDLE_CONTENTS] } }, { diff --git a/plugin/src/main/resources/menu_anim.conf b/plugin/src/main/resources/menu_anim.conf index 0314954..83019ac 100644 --- a/plugin/src/main/resources/menu_anim.conf +++ b/plugin/src/main/resources/menu_anim.conf @@ -118,30 +118,29 @@ items: [ whitePane { material: WHITE_STAINED_GLASS_PANE - data: 0 name: " " } wood { - material: OAK_LOG + material: OAK_WOOD name: " " click { itemAdd { - material: OAK_LOG + material: OAK_WOOD } } } -beef{ - material: COOKED_BEEF +beef { + material: BEEF click { itemAdd { - material: COOKED_BEEF + material: BEEF } } } -stone { +stone{ material: STONE click { itemAdd { diff --git a/plugin/src/main/resources/plugin.yml b/plugin/src/main/resources/plugin.yml index 2d951ea..2cf2190 100644 --- a/plugin/src/main/resources/plugin.yml +++ b/plugin/src/main/resources/plugin.yml @@ -1,7 +1,7 @@ main: 'ru.abstractmenus.AbstractMenus' name: 'AbstractMenus' version: ${version} -api-version: '1.21' +api-version: '26.1' authors: ['Nanit', 'BrainRTP', 'WhyZerVellasskx'] folia-supported: true softdepend: diff --git a/plugin/src/test/java/ru/abstractmenus/addon/AddonManagerIntegrationTest.java b/plugin/src/test/java/ru/abstractmenus/addon/AddonManagerIntegrationTest.java index f09fed4..2a0899e 100644 --- a/plugin/src/test/java/ru/abstractmenus/addon/AddonManagerIntegrationTest.java +++ b/plugin/src/test/java/ru/abstractmenus/addon/AddonManagerIntegrationTest.java @@ -40,8 +40,7 @@ * {@code unloadAll()} strips the registration. * *

Uses a pure-unit {@link StubApi} + the package-private test-only - * {@code AddonManager(Path, AbstractMenusApi)} constructor, since MockBukkit - * does not work with Paper 1.21.11 (see {@code CoreExtensionTest}). + * {@code AddonManager(Path, AbstractMenusApi)} constructor. */ class AddonManagerIntegrationTest { diff --git a/plugin/src/test/java/ru/abstractmenus/addon/TestAddonRegressions.java b/plugin/src/test/java/ru/abstractmenus/addon/TestAddonRegressions.java new file mode 100644 index 0000000..c7c8952 --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/addon/TestAddonRegressions.java @@ -0,0 +1,127 @@ +package ru.abstractmenus.addon; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import ru.abstractmenus.api.AbstractMenusApi; +import ru.abstractmenus.api.MenuExtension; +import ru.abstractmenus.api.Rule; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.serialize.NodeSerializer; +import ru.abstractmenus.testsupport.ApiTestSupport; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class TestAddonRegressions { + public static class Failing implements MenuExtension { + public void onEnable(AbstractMenusApi api) { throw new IllegalStateException("fixture failure"); } + } + + public static class Base implements MenuExtension { + public void onEnable(AbstractMenusApi api) { } + } + + public static class Dependent implements MenuExtension { + public void onEnable(AbstractMenusApi api) { api.rules().register("dependent",SampleRule.class,new SampleRule.Serializer(),this); } + } + + public static class SampleRule implements Rule { + public boolean check(org.bukkit.entity.Player player,ru.abstractmenus.api.inventory.Menu menu,ru.abstractmenus.api.inventory.Item item) { return true; } + public static class Serializer implements NodeSerializer { + public SampleRule deserialize(Class type,ConfigNode node) { return new SampleRule(); } + } + } + + private void jar(Path path,String name,Class main,String dependency,boolean shadeHocon) throws Exception { + try (var out = new JarOutputStream(Files.newOutputStream(path))) { + out.putNextEntry(new JarEntry("addon.conf")); + out.write(("name=\""+name+"\"\nversion=\"1\"\nmain=\""+main.getName()+"\"\n" + +(dependency == null ? "" : "addonDependencies=[\""+dependency+"\"]\n")).getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + for (Class type : List.of(main,SampleRule.class,SampleRule.Serializer.class)) { + String resource = type.getName().replace('.','/')+".class"; + out.putNextEntry(new JarEntry(resource)); + try (var in = type.getClassLoader().getResourceAsStream(resource)) { in.transferTo(out); } + out.closeEntry(); + } + if (shadeHocon) { + try (var source = new JarFile(Path.of(NodeSerializer.class.getProtectionDomain().getCodeSource().getLocation().toURI()).toFile())) { + for (var entry : Collections.list(source.entries())) { + if (entry.isDirectory() || !entry.getName().startsWith("ru/abstractmenus/hocon/")) continue; + out.putNextEntry(new JarEntry(entry.getName())); + try (var in = source.getInputStream(entry)) { in.transferTo(out); } + out.closeEntry(); + } + } + } + } + } + + @Test + void startupDoesNotEnableDependentsOfFailedAddons(@TempDir Path folder) throws Exception { + ru.abstractmenus.api.Logger.set(java.util.logging.Logger.getLogger("addon-test")); + jar(folder.resolve("base.jar"),"Base",Failing.class,null,false); + jar(folder.resolve("bridge.jar"),"Bridge",Dependent.class,"Base",false); + jar(folder.resolve("leaf.jar"),"Leaf",Dependent.class,"Bridge",false); + try (var support = ApiTestSupport.install()) { + var manager = new AddonManager(folder,support.api()); + try { + manager.loadAll(); + assertEquals(AddonStatus.FAILED,manager.get("Base").orElseThrow().getStatus()); + assertEquals(AddonStatus.FAILED,manager.get("Bridge").orElseThrow().getStatus()); + assertEquals(AddonStatus.FAILED,manager.get("Leaf").orElseThrow().getStatus()); + assertNull(support.api().rules().get("dependent")); + } finally { manager.unloadAll(); } + } + } + + @Test + void shadedHoconUsesSharedApiTypes(@TempDir Path folder) throws Exception { + ru.abstractmenus.api.Logger.set(java.util.logging.Logger.getLogger("addon-test")); + jar(folder.resolve("addon.jar"),"Shaded",Dependent.class,null,true); + try (var support = ApiTestSupport.install()) { + var manager = new AddonManager(folder,support.api()); + try { + manager.loadAll(); + var addon = manager.get("Shaded").orElseThrow(); + assertEquals(AddonStatus.ENABLED,addon.getStatus(),String.valueOf(addon.getError())); + assertNotNull(support.api().rules().get("dependent")); + assertSame(NodeSerializer.class,addon.getClassLoader().loadClass(NodeSerializer.class.getName())); + } finally { manager.unloadAll(); } + } + } + + @Test + void rescanEnablesNewDependenciesBeforeDependents(@TempDir Path folder) throws Exception { + ru.abstractmenus.api.Logger.set(java.util.logging.Logger.getLogger("addon-test")); + Files.createFile(folder.resolve("one.jar")); Files.createFile(folder.resolve("two.jar")); + List order; + try (var entries = Files.newDirectoryStream(folder,"*.jar")) { + order = new ArrayList<>(); entries.forEach(order::add); + } + jar(order.get(0),"Bridge",Dependent.class,"Base",false); + jar(order.get(1),"Base",Base.class,null,false); + try (var support = ApiTestSupport.install()) { + var manager = new AddonManager(folder,support.api()); + try { + assertEquals(2,manager.rescan().size()); + assertEquals(AddonStatus.ENABLED,manager.get("Base").orElseThrow().getStatus()); + assertEquals(AddonStatus.ENABLED,manager.get("Bridge").orElseThrow().getStatus()); + assertNotNull(support.api().rules().get("dependent")); + jar(folder.resolve("later.jar"),"Later",Base.class,"Base",false); + assertEquals(1,manager.rescan().size()); + assertEquals(AddonStatus.ENABLED,manager.get("Later").orElseThrow().getStatus()); + } finally { manager.unloadAll(); } + } + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/audit/TestActionsRulesRegressions.java b/plugin/src/test/java/ru/abstractmenus/audit/TestActionsRulesRegressions.java new file mode 100644 index 0000000..2479a14 --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/audit/TestActionsRulesRegressions.java @@ -0,0 +1,129 @@ +package ru.abstractmenus.audit; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockito.MockedStatic; +import ru.abstractmenus.api.AbstractMenusApi; +import ru.abstractmenus.api.inventory.Item; +import ru.abstractmenus.api.inventory.Menu; +import ru.abstractmenus.data.actions.*; +import ru.abstractmenus.data.rules.RuleInventoryItem; +import ru.abstractmenus.datatype.*; +import ru.abstractmenus.hocon.api.*; +import ru.abstractmenus.hocon.api.serialize.*; +import ru.abstractmenus.hocon.api.source.ConfigSources; +import ru.abstractmenus.nms.title.Title; +import ru.abstractmenus.util.bukkit.BukkitTasks; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.lang.reflect.Constructor; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class TestActionsRulesRegressions { + MockedStatic apiStatic; + NodeSerializers serializers; + @BeforeEach void setup() { + MockBukkit.mock().addSimpleWorld("world"); + ru.abstractmenus.api.text.Colors.init(false); + ru.abstractmenus.util.MiniMessageUtil.init(false); + AbstractMenusApi api = mock(AbstractMenusApi.class, RETURNS_DEEP_STUBS); + apiStatic = mockStatic(AbstractMenusApi.class); + apiStatic.when(AbstractMenusApi::get).thenReturn(api); + when(api.providers().placeholders().resolve().replace(any(), anyString())).thenAnswer(i -> i.getArgument(1)); + when(api.providers().placeholders().resolve().replace(any(), anyList())).thenAnswer(i -> i.getArgument(1)); + ru.abstractmenus.api.Logger.set(java.util.logging.Logger.getLogger("audit")); + serializers = NodeSerializers.defaults(); + serializers.register(TypeInt.class,new TypeInt.Serializer()); + serializers.register(TypeFloat.class,new TypeFloat.Serializer()); + serializers.register(TypeBool.class,new TypeBool.Serializer()); + serializers.register(TypeLocation.class,new TypeLocation.Serializer()); + BukkitTasks.setPlugin(null); BukkitTasks.setFoliaLib(null); + } + @AfterEach void cleanup() { apiStatic.close(); MockBukkit.unmock(); } + ConfigNode node(String input) throws Exception { + return ConfigurationLoader.builder().serializers(serializers) + .source(ConfigSources.inputStream("audit",new ByteArrayInputStream(("val = "+input).getBytes(StandardCharsets.UTF_8)))) + .build().load().node("val"); + } + @Test void soundAcceptsExplicitLocation() throws Exception { + assertDoesNotThrow(() -> new ActionSound.Serializer().deserialize(ActionSound.class, + node("{ name = \"entity.player.levelup\", location = \"world,0,64,0\" }"))); + } + @Test void soundAcceptsPlaceholder() throws Exception { + assertDoesNotThrow(() -> new ActionSound.Serializer().deserialize(ActionSound.class, + node("\"%varp_sound%\""))); + } + @Test void customSoundUsesCategory() throws Exception { + assertDoesNotThrow(() -> new ActionSoundCustom.Serializer() + .deserialize(ActionSoundCustom.class,node("{ name = \"custom.bell\", category = \"MASTER\" }"))); + } + @Test void titlePreservesTimingOrder() throws Exception { + Title.init(); + ActionMessage action = new ActionMessage.Serializer().deserialize(ActionMessage.class, + node("{ title = \"Hello\", fadeIn = 5, stay = 100, fadeOut = 20 }")); + Player p = mock(Player.class); + action.activate(p,null,null); + verify(p).sendTitle("Hello","",5,100,20); + } + @Test void brokenInventoryItemFailsClosed() throws Exception { + var item = new ru.abstractmenus.menu.item.SimpleItem(); + item.addProperty("count",new ru.abstractmenus.data.properties.PropCount.Serializer().deserialize( + ru.abstractmenus.data.properties.PropCount.class,node("\"%varp_cost%\""))); + Constructor c=RuleInventoryItem.class.getDeclaredConstructor(List.class); c.setAccessible(true); + RuleInventoryItem rule=c.newInstance(List.of(item)); + assertFalse(rule.check(mock(Player.class),null,null)); + } + @Test void removesRemainderAcrossEquipment() throws Exception { + Player p=MockBukkit.getMock().addPlayer(); + p.getInventory().setHelmet(new ItemStack(org.bukkit.Material.DIAMOND_HELMET)); + p.getInventory().setItemInOffHand(new ItemStack(org.bukkit.Material.DIAMOND_HELMET)); + Item item=mock(Item.class); + when(item.build(any(),any())).thenReturn(new ItemStack(org.bukkit.Material.DIAMOND_HELMET,2)); + Constructor c=ActionItemRemove.class.getDeclaredConstructor();c.setAccessible(true); + ActionItemRemove action=c.newInstance(); + var f=ActionItemRemove.class.getDeclaredField("items");f.setAccessible(true);f.set(action,List.of(item)); + action.activate(p,null,null); + assertNull(p.getInventory().getHelmet()); + assertTrue(p.getInventory().getItemInOffHand().getType().isAir()); + } + @Test void takeXpUsesCurrentLevelAndProgress() { + Player p=mock(Player.class); + when(p.calculateTotalExperiencePoints()).thenReturn(160); + var handler=new ru.abstractmenus.handlers.LevelDefaultHandler(); + assertEquals(160,handler.getXp(p)); + handler.takeXp(p,1); + verify(p).setExperienceLevelAndProgress(159); + verify(p,never()).setLevel(anyInt()); + verify(p,never()).giveExp(anyInt()); + } + @Test void chanceZeroNeverPasses() throws Exception { + var rule=new ru.abstractmenus.data.rules.RuleChance.Serializer().deserialize( + ru.abstractmenus.data.rules.RuleChance.class,node("0")); + try (var random=mockStatic(java.util.concurrent.ThreadLocalRandom.class)) { + var r=mock(java.util.concurrent.ThreadLocalRandom.class); + random.when(java.util.concurrent.ThreadLocalRandom::current).thenReturn(r); + when(r.nextInt(100)).thenReturn(0); + assertFalse(rule.check(null,null,null)); + } + } + @Test void explicitRefreshSlotWorksWithoutClick() throws Exception { + serializers.register(TypeSlot.class,new TypeSlot.Serializer()); + ActionItemRefresh action=new ActionItemRefresh.Serializer().deserialize(ActionItemRefresh.class, + node("{ slot = 0, delay = 1 }")); + Player player=mock(Player.class); + Menu menu=mock(Menu.class); + try (var tasks=mockStatic(BukkitTasks.class)) { + tasks.when(() -> BukkitTasks.runForEntityLater(eq(player),any(Runnable.class),eq(1L))) + .thenAnswer(i -> { ((Runnable)i.getArgument(1)).run(); return null; }); + action.activate(player,menu,null); + verify(menu).refreshItem(any(),eq(player)); + } + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/audit/TestActivatorsPlaceholders.java b/plugin/src/test/java/ru/abstractmenus/audit/TestActivatorsPlaceholders.java new file mode 100644 index 0000000..8700faa --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/audit/TestActivatorsPlaceholders.java @@ -0,0 +1,143 @@ +package ru.abstractmenus.audit; + +import net.citizensnpcs.api.npc.NPC; +import org.bukkit.Material; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import ru.abstractmenus.MainConfig; +import ru.abstractmenus.api.AbstractMenusApi; +import ru.abstractmenus.data.EntityData; +import ru.abstractmenus.data.activators.OpenPlate; +import ru.abstractmenus.data.activators.OpenShiftClickEntity; +import ru.abstractmenus.datatype.TypeLocation; +import ru.abstractmenus.extractors.EntityExtractor; +import ru.abstractmenus.extractors.NPCExtractor; +import ru.abstractmenus.handlers.placeholder.PlaceholderDefaultHandler; +import ru.abstractmenus.placeholders.hooks.VarPlaceholders; +import ru.abstractmenus.variables.VarImpl; +import ru.abstractmenus.variables.VariableManagerImpl; +import ru.abstractmenus.util.TimeUtil; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TestActivatorsPlaceholders { + private AbstractMenusApi api; + private Player player; + private VariableManagerImpl variables; + private PlaceholderDefaultHandler handler; + + @BeforeEach + void setUp() { + var server = MockBukkit.mock(); + var plugin = MockBukkit.createMockPlugin(); + player = server.addPlayer("Alice"); + api = mock(AbstractMenusApi.class, RETURNS_DEEP_STUBS); + handler = new PlaceholderDefaultHandler(); + handler.registerAll(); + when(api.providers().placeholders().resolve()).thenReturn(handler); + server.getServicesManager().register(AbstractMenusApi.class, api, plugin, ServicePriority.Normal); + variables = new VariableManagerImpl(new MainConfig()); + var timeConfig = mock(MainConfig.class); + when(timeConfig.getTimeDay()).thenReturn("d"); + when(timeConfig.getTimeHour()).thenReturn("h"); + when(timeConfig.getTimeMinute()).thenReturn("m"); + when(timeConfig.getTimeSecond()).thenReturn("s"); + TimeUtil.init(timeConfig); + } + + @AfterEach + void tearDown() { + variables.shutdown(); + MockBukkit.unmock(); + } + + private T construct(Class type, List entries) throws Exception { + var constructor = type.getDeclaredConstructor(List.class); + constructor.setAccessible(true); + return constructor.newInstance(entries); + } + + @Test + void shiftClickSupportsOptionalName() throws Exception { + Player sneaking = mock(Player.class); + when(sneaking.isSneaking()).thenReturn(true); + var cow = mock(LivingEntity.class); + when(cow.getType()).thenReturn(EntityType.COW); + when(cow.getName()).thenReturn("Shop"); + for (String name : new String[]{null, "Shop", "Other"}) { + clearInvocations(api); + var activator = construct(OpenShiftClickEntity.class, List.of(new EntityData(EntityType.COW, name))); + var menu = mock(ru.abstractmenus.api.inventory.Menu.class); + activator.setTargetMenu(menu); + activator.onEntityClick(new PlayerInteractEntityEvent(sneaking, cow, EquipmentSlot.HAND)); + verify(api, times("Other".equals(name) ? 0 : 1)).openMenu(activator, cow, sneaking, menu); + } + } + + @Test + void physicalPressurePlateDoesNotNeedHand() throws Exception { + var plate = player.getWorld().getBlockAt(10,64,10); + plate.setType(Material.STONE_PRESSURE_PLATE); + var activator = construct(OpenPlate.class, List.of(new TypeLocation(plate.getLocation()))); + var menu = mock(ru.abstractmenus.api.inventory.Menu.class); + activator.setTargetMenu(menu); + activator.onPlateEnter(new PlayerInteractEvent(player, Action.PHYSICAL, null, plate, BlockFace.SELF, null)); + verify(api).openMenu(activator, plate, player, menu); + } + + @Test + void commonAndLivingEntityKeysRemainAvailable() { + var cow = mock(LivingEntity.class); + when(cow.getType()).thenReturn(EntityType.COW); + when(cow.getName()).thenReturn("Shop"); + when(cow.getLastDamage()).thenReturn(2.0); + assertEquals("COW", EntityExtractor.INSTANCE.extract(cow,"entity_type")); + assertEquals("Shop", EntityExtractor.INSTANCE.extract(cow,"entity_name")); + assertEquals("2.0", EntityExtractor.INSTANCE.extract(cow,"entity_last_damage")); + assertEquals("Alice", EntityExtractor.INSTANCE.extract(player,"player_name")); + } + + @Test + void npcUsesCurrentEntityApi() { + var npc = mock(NPC.class); + var entity = mock(LivingEntity.class); + when(entity.getType()).thenReturn(EntityType.COW); + when(npc.getEntity()).thenReturn(entity); + assertEquals("COW", NPCExtractor.INSTANCE.extract(npc,"npc_entity_type")); + when(npc.getEntity()).thenReturn(null); + assertEquals("", NPCExtractor.INSTANCE.extract(npc,"npc_entity_type")); + } + + @Test + void variableHooksSupportNamesDefaultsAndLegacyForms() { + long expiry = System.currentTimeMillis() + 600000; + variables.cache("#global:treasury",new VarImpl("treasury","42",expiry)); + variables.cache("alice:balance",new VarImpl("balance","17",expiry)); + assertEquals("42",handler.replace(player,"%var_treasury%")); + assertEquals("42",handler.replace(player,"%var_:treasury%")); + assertEquals("42",handler.replace(player,"%var_get:treasury%")); + assertEquals("17",handler.replace(player,"%varp_balance:0%")); + assertEquals("17",handler.replace(player,"%varp_:balance%")); + assertEquals("fallback",handler.replace(player,"%var_missing:fallback%")); + assertEquals("fallback",handler.replace(player,"%varp_missing:fallback%")); + assertEquals("fallback",handler.replace(player,"%vart_missing:fallback%")); + assertEquals("fallback",handler.replace(player,"%varpt_missing:fallback%")); + assertEquals(new VarPlaceholders.VarTempHook().replace(":treasury",player), + handler.replace(player,"%vart_treasury%")); + assertEquals(new VarPlaceholders.VarTempPlayerHook().replace(":balance",player), + handler.replace(player,"%varpt_balance%")); + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/audit/TestCoreInfra.java b/plugin/src/test/java/ru/abstractmenus/audit/TestCoreInfra.java new file mode 100644 index 0000000..267cdb3 --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/audit/TestCoreInfra.java @@ -0,0 +1,101 @@ +package ru.abstractmenus.audit; + +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; +import org.mockbukkit.mockbukkit.MockBukkit; +import ru.abstractmenus.MainConfig; +import ru.abstractmenus.api.AbstractMenusApi; +import ru.abstractmenus.api.variables.Var; +import ru.abstractmenus.command.Command; +import ru.abstractmenus.command.CommandManager; +import ru.abstractmenus.command.args.Argument; +import ru.abstractmenus.commands.varp.VarpSet; +import ru.abstractmenus.handlers.SkinsRestorerHandler; +import ru.abstractmenus.handlers.placeholder.PlaceholderDefaultHandler; +import ru.abstractmenus.util.TimeUtil; +import ru.abstractmenus.variables.VariableManagerImpl; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TestCoreInfra { + private org.mockbukkit.mockbukkit.ServerMock server; + private org.bukkit.plugin.Plugin plugin; + + @BeforeEach + void setUp() { + server = MockBukkit.mock(); + plugin = MockBukkit.createMockPlugin("AbstractMenus"); + ru.abstractmenus.api.text.Colors.init(false); + ru.abstractmenus.api.Logger.set(plugin.getLogger()); + var config = mock(MainConfig.class); + when(config.getTimeDay()).thenReturn("d"); + when(config.getTimeHour()).thenReturn("h"); + when(config.getTimeMinute()).thenReturn("m"); + when(config.getTimeSecond()).thenReturn("s"); + TimeUtil.init(config); + } + + @AfterEach + void tearDown() { MockBukkit.unmock(); } + + @Test + void adjacentPlaceholdersResolveIndependently() { + var handler = new PlaceholderDefaultHandler(); + handler.registerAll(); + var player = server.addPlayer("Alice"); player.setLevel(7); + assertEquals("Alice/7",handler.replace(player,"%player_name%/%player_level%")); + } + + @Test + void varpSetUsesTimeAndReplaceArgumentsAndHandlesMissingValue() { + var manager = mock(VariableManagerImpl.class); + when(manager.createBuilder()).thenAnswer(call -> new ru.abstractmenus.variables.VarImpl("","",0).toBuilder()); + var sender = mock(CommandSender.class); + try (var singleton = mockStatic(VariableManagerImpl.class)) { + singleton.when(VariableManagerImpl::instance).thenReturn(manager); + var command = new VarpSet(); + long before = System.currentTimeMillis(); + command.execute(sender,new String[]{"Alice","buff","on","5m","false"}); + var variable = ArgumentCaptor.forClass(Var.class); + verify(manager).savePersonal(eq("Alice"),variable.capture(),eq(false)); + assertEquals("on",variable.getValue().value()); + assertTrue(variable.getValue().expiry() >= before + 300000); + assertTrue(variable.getValue().expiry() <= System.currentTimeMillis()+300000); + clearInvocations(manager,sender); + assertDoesNotThrow(() -> command.execute(sender,new String[]{"Alice","buff"})); + verify(sender).sendMessage(any(String[].class)); + verifyNoInteractions(manager); + } + } + + @Test + void namespacedCommandsExecuteAndCompleteThroughWrapper() { + var manager = new CommandManager(plugin); + var argument = mock(Argument.class); + when(argument.suggest(any())).thenReturn(List.of("stone","sand")); + var command = new Command("shop",List.of("store"),List.of(argument),"%s","%s",false); + var calls = new AtomicInteger(); + command.setHandler((sender,context) -> calls.incrementAndGet()); + manager.register(command); + var wrapper = server.getCommandMap().getCommand("abstractmenus:shop"); + wrapper.execute(server.getConsoleSender(),"abstractmenus:shop",new String[0]); + wrapper.execute(server.getConsoleSender(),"shop",new String[0]); + assertEquals(2,calls.get()); + assertEquals(List.of("stone","sand"),wrapper.tabComplete(server.getConsoleSender(),"abstractmenus:shop",new String[]{"s"})); + assertSame(command,manager.get("abstractmenus:store")); + } + + @Test + void proxySkinModeRegistersOutgoingChannelIndependently() { + assertFalse(server.getMessenger().isOutgoingChannelRegistered(plugin,"abstractmenus:main")); + new SkinsRestorerHandler(true,plugin); + assertTrue(server.getMessenger().isOutgoingChannelRegistered(plugin,"abstractmenus:main")); + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/core/CoreExtensionTest.java b/plugin/src/test/java/ru/abstractmenus/core/CoreExtensionTest.java index e6f0243..0cb2021 100644 --- a/plugin/src/test/java/ru/abstractmenus/core/CoreExtensionTest.java +++ b/plugin/src/test/java/ru/abstractmenus/core/CoreExtensionTest.java @@ -34,9 +34,7 @@ * *

This test does NOT boot the full plugin โ€” it constructs a lightweight * {@link StubApi} backed by real {@link TypeRegistryImpl} instances and invokes - * {@code CoreExtension.onEnable()} directly, bypassing the MockBukkit - * incompatibility with Paper 1.21.11 that prevents the full plugin from loading - * in tests (see {@link ru.abstractmenus.integration.TestPluginLifecycle}). + * {@code CoreExtension.onEnable()} directly. * *

Activator count note: {@link CoreActivatorsBundle} conditionally * registers "clickNPC" (Citizens) and "regionJoin"/"regionLeave" (WorldGuard) @@ -52,6 +50,7 @@ class CoreExtensionTest { @BeforeEach void setUp() { + ru.abstractmenus.api.Logger.set(java.util.logging.Logger.getLogger(CoreExtensionTest.class.getName())); // CoreActivatorsBundle calls AbstractMenus.checkDependency() which calls // Bukkit.getServer().getPluginManager().isPluginEnabled(...). // Mock the static Bukkit.getServer() so the call doesn't NPE. @@ -90,8 +89,8 @@ void coreExtension_registersExpectedRules() { @Test void coreExtension_registersExpectedItemProperties() { new CoreExtension().onEnable(api); - assertEquals(33, api.itemProperties().keys().size(), - "expected 33 core item-property types (matches Task 10 migration count)"); + assertEquals(37, api.itemProperties().keys().size(), + "expected 37 core item-property types"); } @Test diff --git a/plugin/src/test/java/ru/abstractmenus/data/properties/TestAttributeModifier.java b/plugin/src/test/java/ru/abstractmenus/data/properties/TestAttributeModifier.java new file mode 100644 index 0000000..a2bed67 --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/data/properties/TestAttributeModifier.java @@ -0,0 +1,81 @@ +package ru.abstractmenus.data.properties; +import org.bukkit.Material; +import org.bukkit.attribute.Attribute; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import ru.abstractmenus.hocon.api.*; +import ru.abstractmenus.hocon.api.source.ConfigSources; +import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; +import java.io.*; +import java.nio.charset.StandardCharsets; +import static org.junit.jupiter.api.Assertions.*; +class TestAttributeModifier { + @BeforeEach void setup(){MockBukkit.mock();} + @AfterEach void done(){MockBukkit.unmock();} + private ConfigNode node(String text) throws Exception { + return ConfigurationLoader.builder().source(ConfigSources.inputStream("audit",new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)))).build().load().node("attributeModifier"); + } + @Test void legacyKeyResolves() throws Exception { + var n=node("attributeModifier: [{type: \"generic.attack_damage\", amount: 3, slot: HAND}]".replace("\\\"","\"")); + var prop=new PropAttributeModifier.Serializer().deserialize(PropAttributeModifier.class,n); + var item=new ItemStack(Material.DIAMOND_SWORD);var meta=item.getItemMeta(); + prop.apply(item,meta,null,null); + assertEquals(3,meta.getAttributeModifiers(Attribute.ATTACK_DAMAGE).iterator().next().getAmount()); + } + @Test void repeatedAttributeKeepsBothSlots() throws Exception { + var n=node("attributeModifier: [{type: attack_damage, amount: 3, slot: HAND}, {type: attack_damage, amount: 7, slot: OFF_HAND}]"); + var prop=new PropAttributeModifier.Serializer().deserialize(PropAttributeModifier.class,n); + var item=new ItemStack(Material.DIAMOND_SWORD);var meta=item.getItemMeta(); + prop.apply(item,meta,null,null); + var modifiers=meta.getAttributeModifiers(Attribute.ATTACK_DAMAGE); + assertEquals(2,modifiers.size()); + assertEquals(java.util.Set.of(3.0,7.0),modifiers.stream().map(org.bukkit.attribute.AttributeModifier::getAmount).collect(java.util.stream.Collectors.toSet())); + } + @Test void baseAndBindingKeepDistinctStableModifiers() throws Exception { + ru.abstractmenus.api.Logger.set(java.util.logging.Logger.getLogger("audit")); + var first=new PropAttributeModifier.Serializer().deserialize(PropAttributeModifier.class,node("attributeModifier: [{type: attack_damage, amount: 3, slot: HAND}]")); + var second=new PropAttributeModifier.Serializer().deserialize(PropAttributeModifier.class,node("attributeModifier: [{type: attack_damage, amount: 7, slot: OFF_HAND}]")); + var group=new PropBindings.BindGroup(java.util.List.of(second),(p,m,i)->true); + var ctor=PropBindings.class.getDeclaredConstructor(java.util.List.class);ctor.setAccessible(true); + var binding=ctor.newInstance(java.util.List.of(group)); + var item=new ru.abstractmenus.menu.item.SimpleItem(); + item.addProperty("attributeModifier",first);item.addProperty("bindings",binding); + var built=item.build(null,null); + assertNotNull(built); + assertEquals(2,built.getItemMeta().getAttributeModifiers(Attribute.ATTACK_DAMAGE).size()); + assertTrue(built.isSimilar(item.build(null,null))); + } + @Test void unknownAttributeReportsNode() throws Exception { + var n=node("attributeModifier: [{type: does_not_exist}]"); + var ex=assertThrows(NodeSerializeException.class,()->new PropAttributeModifier.Serializer().deserialize(PropAttributeModifier.class,n)); + assertTrue(ex.getMessage().contains("does_not_exist")); + assertTrue(ex.getMessage().contains("attributeModifier")); + } + @Test void separateDeserializationsBuildSimilarItems() throws Exception { + String config="attributeModifier: [{type: attack_damage, amount: 3, slot: HAND}, {type: attack_damage, amount: 7, slot: OFF_HAND}]"; + ItemStack first=buildProperty(config); + ItemStack second=buildProperty(config); + assertTrue(first.isSimilar(second)); + } + @Test void identicalPropertiesAreAppliedOnce() throws Exception { + String config="attributeModifier: [{type: attack_damage, amount: 3, slot: HAND}]"; + var first=new PropAttributeModifier.Serializer().deserialize(PropAttributeModifier.class,node(config)); + var second=new PropAttributeModifier.Serializer().deserialize(PropAttributeModifier.class,node(config)); + var item=new ItemStack(Material.DIAMOND_SWORD);var meta=item.getItemMeta(); + first.apply(item,meta,null,null); + assertDoesNotThrow(()->second.apply(item,meta,null,null)); + assertEquals(1,meta.getAttributeModifiers(Attribute.ATTACK_DAMAGE).size()); + } + @Test void identicalListEntriesRemainDistinct() throws Exception { + var item=buildProperty("attributeModifier: [{type: attack_damage, amount: 3, slot: HAND}, {type: attack_damage, amount: 3, slot: HAND}]"); + assertEquals(2,item.getItemMeta().getAttributeModifiers(Attribute.ATTACK_DAMAGE).size()); + } + private ItemStack buildProperty(String config) throws Exception { + var property=new PropAttributeModifier.Serializer().deserialize(PropAttributeModifier.class,node(config)); + var item=new ItemStack(Material.DIAMOND_SWORD);var meta=item.getItemMeta(); + property.apply(item,meta,null,null); + item.setItemMeta(meta); + return item; + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropCount.java b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropCount.java new file mode 100644 index 0000000..5832b32 --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropCount.java @@ -0,0 +1,52 @@ +package ru.abstractmenus.data.properties; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import ru.abstractmenus.datatype.TypeInt; +import ru.abstractmenus.datatype.TypeMaterial; +import ru.abstractmenus.menu.item.SimpleItem; +import ru.abstractmenus.hocon.api.serialize.NodeSerializers; +import ru.abstractmenus.hocon.api.ConfigurationLoader; +import ru.abstractmenus.hocon.api.source.ConfigSources; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class TestPropCount { + + @BeforeEach + void setUp() { + MockBukkit.mock(); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void buildKeepsConfiguredCountOnUnstackableItem() throws Exception { + NodeSerializers serializers = NodeSerializers.defaults(); + serializers.register(TypeInt.class, new TypeInt.Serializer()); + serializers.register(TypeMaterial.class, new TypeMaterial.Serializer()); + var root = ConfigurationLoader.builder() + .serializers(serializers) + .source(ConfigSources.inputStream("test", new ByteArrayInputStream("material: DIAMOND_SWORD\ncount: 3".getBytes(StandardCharsets.UTF_8)))) + .build().load(); + SimpleItem template = new SimpleItem(); + template.addProperty("material", new PropMaterial.Serializer().deserialize(PropMaterial.class, root.node("material"))); + template.addProperty("count", new PropCount.Serializer().deserialize(PropCount.class, root.node("count"))); + + ItemStack sword = template.build(null, null); + + assertNotNull(sword); + assertEquals(Material.DIAMOND_SWORD, sword.getType()); + assertEquals(3, sword.getAmount()); + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropFlags.java b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropFlags.java new file mode 100644 index 0000000..687e53a --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropFlags.java @@ -0,0 +1,51 @@ +package ru.abstractmenus.data.properties; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import ru.abstractmenus.hocon.api.ConfigurationLoader; +import ru.abstractmenus.hocon.api.source.ConfigSources; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class TestPropFlags { + + @BeforeEach + void setUp() { + MockBukkit.mock(); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void hidingAttributesPreservesCustomModifiers() throws Exception { + var root = ConfigurationLoader.builder() + .source(ConfigSources.inputStream("test", new ByteArrayInputStream("flags: HIDE_ATTRIBUTES".getBytes(StandardCharsets.UTF_8)))) + .build().load(); + PropFlags property = new PropFlags.Serializer().deserialize(PropFlags.class, root.node("flags")); + ItemStack sword = new ItemStack(Material.DIAMOND_SWORD); + ItemMeta meta = sword.getItemMeta(); + AttributeModifier modifier = new AttributeModifier(NamespacedKey.minecraft("test_damage"), 12, AttributeModifier.Operation.ADD_NUMBER); + meta.addAttributeModifier(Attribute.ATTACK_DAMAGE, modifier); + + property.apply(sword, meta, null, null); + sword.setItemMeta(meta); + + assertTrue(sword.getItemMeta().hasItemFlag(ItemFlag.HIDE_ATTRIBUTES)); + assertEquals(java.util.List.of(modifier), java.util.List.copyOf(sword.getItemMeta().getAttributeModifiers(Attribute.ATTACK_DAMAGE))); + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropMaxStackSize.java b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropMaxStackSize.java new file mode 100644 index 0000000..18d0b52 --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropMaxStackSize.java @@ -0,0 +1,72 @@ +package ru.abstractmenus.data.properties; + +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import ru.abstractmenus.api.AbstractMenusApi; +import ru.abstractmenus.api.Logger; +import ru.abstractmenus.menu.item.SimpleItem; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.ConfigurationLoader; +import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; +import ru.abstractmenus.hocon.api.source.ConfigSources; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TestPropMaxStackSize { + + @Test + void rejectsOutOfRangeAtMenuLoad() throws Exception { + for (int size : new int[]{0, -1, 100, Integer.MAX_VALUE}) { + ConfigNode node = loadValue(size); + assertThrows(NodeSerializeException.class, () -> new PropMaxStackSize.Serializer().deserialize(PropMaxStackSize.class, node)); + } + } + + @Test + void acceptsBoundsAndNormalStackSize() throws Exception { + for (int size : new int[]{1, 64, 99}) { + PropMaxStackSize property = new PropMaxStackSize.Serializer().deserialize(PropMaxStackSize.class, loadValue(size)); + ItemMeta meta = mock(ItemMeta.class); + property.apply(null, meta, null, null); + verify(meta).setMaxStackSize(size); + } + } + + @Test + void invalidPlaceholderValueWarnsAndKeepsTheItemAndExistingLimit() throws Exception { + MockBukkit.mock(); + AbstractMenusApi api = mock(AbstractMenusApi.class, RETURNS_DEEP_STUBS); + try (var apiLookup = mockStatic(AbstractMenusApi.class); var logging = mockStatic(Logger.class)) { + apiLookup.when(AbstractMenusApi::get).thenReturn(api); + for (int size : new int[]{0, 100}) { + when(api.providers().placeholders().resolve().replace(null, "%stack_size%")).thenReturn(String.valueOf(size)); + SimpleItem template = new SimpleItem(); + template.addProperty("initialStackSize", new PropMaxStackSize.Serializer().deserialize(PropMaxStackSize.class, loadValue(32))); + template.addProperty("maxStackSize", new PropMaxStackSize.Serializer().deserialize(PropMaxStackSize.class, loadNode("\"%stack_size%\""))); + + var item = template.build(null, null); + + assertNotNull(item); + assertEquals(32, item.getMaxStackSize()); + logging.verify(() -> Logger.warning("Skipping maxStackSize value outside 1..99: " + size)); + } + } finally { + MockBukkit.unmock(); + } + } + + private static ConfigNode loadValue(int size) throws Exception { + return loadNode(String.valueOf(size)); + } + + private static ConfigNode loadNode(String value) throws Exception { + return ConfigurationLoader.builder() + .source(ConfigSources.inputStream("test", new ByteArrayInputStream(("value: " + value).getBytes(StandardCharsets.UTF_8)))) + .build().load().node("value"); + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropModel.java b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropModel.java new file mode 100644 index 0000000..4cd7d1c --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropModel.java @@ -0,0 +1,68 @@ +package ru.abstractmenus.data.properties; + +import org.bukkit.Color; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.components.CustomModelDataComponent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.ConfigurationLoader; +import ru.abstractmenus.hocon.api.source.ConfigSources; +import ru.abstractmenus.menu.item.SimpleItem; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class TestPropModel { + + @BeforeEach + void setUp() { + MockBukkit.mock(); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void legacyIntegerBecomesSingleFloat() throws Exception { + CustomModelDataComponent model = build("model: 123"); + assertEquals(List.of(123.0f), model.getFloats()); + assertTrue(model.getFlags().isEmpty()); + assertTrue(model.getStrings().isEmpty()); + assertTrue(model.getColors().isEmpty()); + } + + @Test + void objectRetainsAllFourTypedLists() throws Exception { + CustomModelDataComponent model = build(""" + model { + floats: [1, 2.5] + flags: [true, false] + strings: ["cake", "bundle"] + colors: ["#ff8000", "0, 64, 255"] + } + """); + assertEquals(List.of(1.0f, 2.5f), model.getFloats()); + assertEquals(List.of(true, false), model.getFlags()); + assertEquals(List.of("cake", "bundle"), model.getStrings()); + assertEquals(List.of(Color.fromRGB(255, 128, 0), Color.fromRGB(0, 64, 255)), model.getColors()); + } + + private static CustomModelDataComponent build(String hocon) throws Exception { + ConfigNode root = ConfigurationLoader.builder() + .source(ConfigSources.inputStream("test", new ByteArrayInputStream(hocon.getBytes(StandardCharsets.UTF_8)))) + .build().load(); + SimpleItem template = new SimpleItem(); + template.addProperty("model", new PropModel.Serializer().deserialize(PropModel.class, root.node("model"))); + ItemStack item = template.build(null, null); + assertNotNull(item); + return item.getItemMeta().getCustomModelDataComponent(); + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropTooltipDisplay.java b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropTooltipDisplay.java index f820dbf..b2571da 100644 --- a/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropTooltipDisplay.java +++ b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropTooltipDisplay.java @@ -1,194 +1,116 @@ package ru.abstractmenus.data.properties; -import io.papermc.paper.datacomponent.DataComponentType; -import org.bukkit.NamespacedKey; +import io.papermc.paper.datacomponent.DataComponentTypes; +import io.papermc.paper.datacomponent.item.TooltipDisplay; +import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import ru.abstractmenus.datatype.TypeBool; +import org.mockbukkit.mockbukkit.MockBukkit; +import ru.abstractmenus.api.text.Colors; import ru.abstractmenus.hocon.api.ConfigNode; import ru.abstractmenus.hocon.api.ConfigurationLoader; import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; import ru.abstractmenus.hocon.api.source.ConfigSources; +import ru.abstractmenus.menu.item.SimpleItem; +import ru.abstractmenus.util.MiniMessageUtil; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; -import java.lang.reflect.Field; -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.List; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -/** - * Tests the {@code tooltipDisplay} item property's config parsing and its - * {@code hiddenComponents} component resolver. - * - *

The resolver is backed by the injectable {@link Registry#DATA_COMPONENT_TYPE} - * source, so case-insensitive / namespaced resolution and unknown-component - * handling are covered without a live server (see - * {@code PropTooltipDisplay.configureRegistry}). - * - *

Full {@code apply()} verification needs the Paper {@code TooltipDisplay} - * builder bridge, which requires a booted server (MockBukkit cannot bootstrap - * Paper 1.21.11) โ€” see README's Tests section. - */ class TestPropTooltipDisplay { - private static final Map COMPONENTS = new LinkedHashMap<>(); - @BeforeEach void setUp() { - COMPONENTS.clear(); - COMPONENTS.put("bundle_contents", component("bundle_contents")); - COMPONENTS.put("enchantments", component("enchantments")); - COMPONENTS.put("attribute_modifiers", component("attribute_modifiers")); - COMPONENTS.put("stored_enchantments", component("stored_enchantments")); - COMPONENTS.put("custom_model_data", component("custom_model_data")); - - PropTooltipDisplay.configureRegistry(COMPONENTS.values()); + MockBukkit.mock(); + MiniMessageUtil.init(false); + Colors.init(false); } @AfterEach void tearDown() { - PropTooltipDisplay.resetRegistry(); - } - - // ----- resolver ----- - - @Test - void resolvesUppercaseName() { - assertSame(COMPONENTS.get("bundle_contents"), - PropTooltipDisplay.resolveComponent("BUNDLE_CONTENTS")); - } - - @Test - void resolvesLowercaseName() { - assertSame(COMPONENTS.get("bundle_contents"), - PropTooltipDisplay.resolveComponent("bundle_contents")); - } - - @Test - void resolvesNamespacedKey() { - assertSame(COMPONENTS.get("bundle_contents"), - PropTooltipDisplay.resolveComponent("minecraft:bundle_contents")); - } - - @Test - void resolveIsCaseInsensitiveForNamespacedInput() { - assertSame(COMPONENTS.get("bundle_contents"), - PropTooltipDisplay.resolveComponent("MINECRAFT:BUNDLE_CONTENTS")); - } - - @Test - void unknownComponentResolvesToNull() { - assertNull(PropTooltipDisplay.resolveComponent("NOPE_NOT_A_COMPONENT")); - } - - @Test - void nullComponentResolvesToNull() { - assertNull(PropTooltipDisplay.resolveComponent(null)); - assertNull(PropTooltipDisplay.resolveComponent(" ")); - } - - // ----- serialization ----- - - @Test - void parsesHideTooltipTrue() throws Exception { - PropTooltipDisplay prop = build("tooltipDisplay { hideTooltip: true }"); - TypeBool hide = (TypeBool) field(prop, "hideTooltip"); - assertNotNull(hide); - assertTrue(hide.getBool(null, null)); + MockBukkit.unmock(); } @Test - void parsesHideTooltipFalse() throws Exception { - PropTooltipDisplay prop = build("tooltipDisplay { hideTooltip: false }"); - TypeBool hide = (TypeBool) field(prop, "hideTooltip"); - assertNotNull(hide); - assertFalse(hide.getBool(null, null)); + void survivesMetaPropertiesOnBothSidesWhenBuildingMenuItem() throws Exception { + ConfigNode root = loadNode(""" + name: "Named bundle" + tooltipDisplay { + hideTooltip: true + hiddenComponents: [BUNDLE_CONTENTS, "minecraft:enchantments"] + } + lore: ["Still named and hidden"] + """); + SimpleItem template = new SimpleItem(); + template.addProperty("name", new PropName.Serializer().deserialize(PropName.class, root.node("name"))); + PropTooltipDisplay tooltipProperty = spy(new PropTooltipDisplay.Serializer().deserialize(PropTooltipDisplay.class, root.node("tooltipDisplay"))); + // MockBukkit retains component data when stale meta is applied, so also verify the flush boundary. + doAnswer(invocation -> { + ItemStack stack = invocation.getArgument(0); + assertEquals("Named bundle", stack.getItemMeta().getDisplayName()); + return invocation.callRealMethod(); + }).when(tooltipProperty).apply(any(), any(), any(), any()); + template.addProperty("tooltipDisplay", tooltipProperty); + template.addProperty("lore", new PropLore.Serializer().deserialize(PropLore.class, root.node("lore"))); + + ItemStack item = template.build(null, null); + + assertNotNull(item); + assertEquals("Named bundle", item.getItemMeta().getDisplayName()); + assertEquals(List.of("Still named and hidden"), item.getItemMeta().getLore()); + TooltipDisplay tooltip = item.getData(DataComponentTypes.TOOLTIP_DISPLAY); + assertNotNull(tooltip); + assertTrue(tooltip.hideTooltip()); + assertEquals(Set.of(DataComponentTypes.BUNDLE_CONTENTS, DataComponentTypes.ENCHANTMENTS), tooltip.hiddenComponents()); } @Test - void parsesHiddenComponentsCaseInsensitive() throws Exception { - PropTooltipDisplay prop = build("tooltipDisplay { hiddenComponents: [BUNDLE_CONTENTS] }"); - Set hidden = hidden(prop); - assertEquals(Set.of(COMPONENTS.get("bundle_contents")), hidden); + void hiddenComponentsPreserveFlagsAppliedBeforeTooltipDisplay() throws Exception { + ConfigNode root = loadNode(""" + flags: HIDE_ENCHANTS + tooltipDisplay { hiddenComponents: [BUNDLE_CONTENTS] } + """); + SimpleItem template = new SimpleItem(); + template.addProperty("flags", new PropFlags.Serializer().deserialize(PropFlags.class, root.node("flags"))); + PropTooltipDisplay tooltipProperty = spy(new PropTooltipDisplay.Serializer().deserialize(PropTooltipDisplay.class, root.node("tooltipDisplay"))); + // MockBukkit stores flags separately, so mirror Paper's tooltip component conversion at this boundary. + doAnswer(invocation -> { + ItemStack stack = invocation.getArgument(0); + assertTrue(stack.getItemMeta().hasItemFlag(org.bukkit.inventory.ItemFlag.HIDE_ENCHANTS)); + stack.setData(DataComponentTypes.TOOLTIP_DISPLAY, TooltipDisplay.tooltipDisplay().hiddenComponents(Set.of(DataComponentTypes.ENCHANTMENTS)).build()); + return invocation.callRealMethod(); + }).when(tooltipProperty).apply(any(), any(), any(), any()); + template.addProperty("tooltipDisplay", tooltipProperty); + + ItemStack item = template.build(null, null); + + assertNotNull(item); + TooltipDisplay tooltip = item.getData(DataComponentTypes.TOOLTIP_DISPLAY); + assertNotNull(tooltip); + assertEquals(Set.of(DataComponentTypes.ENCHANTMENTS, DataComponentTypes.BUNDLE_CONTENTS), tooltip.hiddenComponents()); } @Test - void parsesHiddenComponentsNamespaced() throws Exception { - PropTooltipDisplay prop = build("tooltipDisplay { hiddenComponents: [\"minecraft:bundle_contents\"] }"); - assertEquals(Set.of(COMPONENTS.get("bundle_contents")), hidden(prop)); - } - - @Test - void parsesMultipleHiddenComponents() throws Exception { - PropTooltipDisplay prop = build( - "tooltipDisplay { hiddenComponents: [BUNDLE_CONTENTS, ENCHANTMENTS, attribute_modifiers] }"); - assertEquals(Set.of( - COMPONENTS.get("bundle_contents"), - COMPONENTS.get("enchantments"), - COMPONENTS.get("attribute_modifiers")), hidden(prop)); - } - - @Test - void unknownComponentThrows() throws Exception { - ConfigNode root = loadNode("tooltipDisplay { hiddenComponents: [FUCKING_UNKNOWN_COMPONENT] }"); + void rejectsUnknownComponentAndNonList() throws Exception { + ConfigNode root = loadNode(""" + unknown { hiddenComponents: [UNKNOWN_COMPONENT] } + invalid { hiddenComponents: BUNDLE_CONTENTS } + """); PropTooltipDisplay.Serializer serializer = new PropTooltipDisplay.Serializer(); - assertThrows(NodeSerializeException.class, () -> - serializer.deserialize(PropTooltipDisplay.class, root.node("tooltipDisplay"))); - } - - @Test - void emptyHiddenComponentsIsAccepted() throws Exception { - PropTooltipDisplay prop = build("tooltipDisplay { hiddenComponents: [] }"); - assertEquals(Set.of(), hidden(prop)); - } - - @Test - void emptyTooltipDisplayLeavesFieldsNull() throws Exception { - PropTooltipDisplay prop = build("tooltipDisplay { }"); - assertNull(field(prop, "hideTooltip")); - assertNull(field(prop, "hiddenComponents")); - } - - @Test - void hideTooltipOnlyLeavesHiddenNull() throws Exception { - PropTooltipDisplay prop = build("tooltipDisplay { hideTooltip: false }"); - assertNull(field(prop, "hiddenComponents")); - } - - // ----- helpers ----- - - private static PropTooltipDisplay build(String hocon) throws Exception { - ConfigNode root = loadNode(hocon); - return new PropTooltipDisplay.Serializer().deserialize(PropTooltipDisplay.class, root.node("tooltipDisplay")); - } - - @SuppressWarnings("unchecked") - private static Set hidden(PropTooltipDisplay prop) throws Exception { - return (Set) field(prop, "hiddenComponents"); - } - - private static Object field(Object target, String name) throws Exception { - Field f = PropTooltipDisplay.class.getDeclaredField(name); - f.setAccessible(true); - return f.get(target); + assertThrows(NodeSerializeException.class, () -> serializer.deserialize(PropTooltipDisplay.class, root.node("unknown"))); + assertThrows(NodeSerializeException.class, () -> serializer.deserialize(PropTooltipDisplay.class, root.node("invalid"))); } private static ConfigNode loadNode(String hocon) throws Exception { - ByteArrayInputStream in = new ByteArrayInputStream(hocon.getBytes(StandardCharsets.UTF_8)); return ConfigurationLoader.builder() - .source(ConfigSources.inputStream("test", in)) - .build() - .load(); - } - - private static DataComponentType component(String key) { - DataComponentType type = mock(DataComponentType.class); - when(type.getKey()).thenReturn(NamespacedKey.minecraft(key)); - return type; + .source(ConfigSources.inputStream("test", new ByteArrayInputStream(hocon.getBytes(StandardCharsets.UTF_8)))) + .build().load(); } } diff --git a/plugin/src/test/java/ru/abstractmenus/extractors/TestItemStackExtractor.java b/plugin/src/test/java/ru/abstractmenus/extractors/TestItemStackExtractor.java new file mode 100644 index 0000000..363008c --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/extractors/TestItemStackExtractor.java @@ -0,0 +1,43 @@ +package ru.abstractmenus.extractors; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TestItemStackExtractor { + @BeforeEach + void setUp() { MockBukkit.mock(); } + + @AfterEach + void tearDown() { MockBukkit.unmock(); } + + @Test + void modelWithoutLegacyNumberIsEmpty() { + ItemStack item = new ItemStack(Material.STONE); + assertEquals("", ItemStackExtractor.INSTANCE.extract(item, "item_model")); + var meta = item.getItemMeta(); + var model = meta.getCustomModelDataComponent(); + model.setStrings(java.util.List.of("example")); + meta.setCustomModelDataComponent(model); + item.setItemMeta(meta); + assertEquals("", ItemStackExtractor.INSTANCE.extract(item, "item_model")); + meta.setCustomModelData(17); + item.setItemMeta(meta); + assertEquals("17", ItemStackExtractor.INSTANCE.extract(item, "item_model")); + } + + @Test + void localizedNameUsesDisplayName() { + ItemStack item = new ItemStack(Material.STONE); + assertEquals("", ItemStackExtractor.INSTANCE.extract(item, "item_localized_name")); + var meta = item.getItemMeta(); + meta.setDisplayName("Menu stone"); + item.setItemMeta(meta); + assertEquals("Menu stone", ItemStackExtractor.INSTANCE.extract(item, "item_localized_name")); + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/impl/ProviderRegistryImplTest.java b/plugin/src/test/java/ru/abstractmenus/impl/ProviderRegistryImplTest.java index b311e20..428abb7 100644 --- a/plugin/src/test/java/ru/abstractmenus/impl/ProviderRegistryImplTest.java +++ b/plugin/src/test/java/ru/abstractmenus/impl/ProviderRegistryImplTest.java @@ -83,6 +83,18 @@ void unregisterAll_removesOnlyThatOwner() { assertEquals(1, registry.economy().all().size()); } + @Test + void replacingProviderTransfersOwnerCleanup() { + EconomyHandler first = mock(EconomyHandler.class); + EconomyHandler second = mock(EconomyHandler.class); + registry.economy().register("points", first, 50, ownerA); + registry.economy().register("points", second, 50, ownerB); + registry.unregisterAll(ownerA); + assertSame(second, registry.economy().resolve("points")); + registry.unregisterAll(ownerB); + assertNull(registry.economy().resolve("points")); + } + @Test void overwrite_replacesPrevious() { EconomyHandler old = mock(EconomyHandler.class); diff --git a/plugin/src/test/java/ru/abstractmenus/integration/TestPluginLifecycle.java b/plugin/src/test/java/ru/abstractmenus/integration/TestPluginLifecycle.java index 0640d44..dee5241 100644 --- a/plugin/src/test/java/ru/abstractmenus/integration/TestPluginLifecycle.java +++ b/plugin/src/test/java/ru/abstractmenus/integration/TestPluginLifecycle.java @@ -1,27 +1,19 @@ package ru.abstractmenus.integration; +import io.papermc.paper.configuration.ServerConfiguration; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockbukkit.mockbukkit.MockBukkit; import org.mockbukkit.mockbukkit.ServerMock; import ru.abstractmenus.AbstractMenus; import ru.abstractmenus.services.MenuManager; +import ru.abstractmenus.util.bukkit.BukkitTasks; +import ru.abstractmenus.util.bukkit.Events; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; -/** - * First-level MockBukkit smoke tests: the plugin boots, registers its services, - * and shuts down cleanly. Heavier flow tests (open menu, click, actions) follow. - * - * Currently disabled: MockBukkit 4.108.0 + Paper 1.21.11-R0.1-SNAPSHOT fails during - * {@code MockBukkit.mock()} with {@code PaperRegistryAccess: RegistryKeyImpl[key=minecraft:attribute] - * points to a registry that is not available yet} (TagsMock.loadRegistry โ†’ Registry.{@literal }). - * Upstream compatibility issue โ€” re-enable once MockBukkit tracks the current Paper build - * or once we pin Paper to a version MockBukkit supports. - */ -@Disabled("MockBukkit 4.108 vs Paper 1.21.11 registry bootstrap incompatibility") class TestPluginLifecycle { private ServerMock server; @@ -29,13 +21,26 @@ class TestPluginLifecycle { @BeforeEach void startServer() { - server = MockBukkit.mock(); + server = MockBukkit.mock(new ServerMock() { + private final ServerConfiguration configuration = mock(ServerConfiguration.class); + + @Override + public ServerConfiguration getServerConfig() { + return configuration; + } + }); plugin = MockBukkit.load(AbstractMenus.class); } @AfterEach void stopServer() { - MockBukkit.unmock(); + try { + MockBukkit.unmock(); + } finally { + BukkitTasks.setFoliaLib(null); + BukkitTasks.setPlugin(null); + Events.setPlugin(null); + } } @Test @@ -50,6 +55,20 @@ void menuManagerSingletonIsInitialized() { "MenuManager.instance() must be set during onEnable()"); } + @Test + void bundledCakeUsesVanillaTooltipStyle() throws Exception { + var manager = MenuManager.instance(); + manager.loadMenus(); + var player = server.addPlayer(); + var template = manager.getMenu("menu"); + assertNotNull(template); + manager.openMenu(player, template); + var cake = manager.getOpenedMenu(player).getInventory().getItem(4); + assertNotNull(cake); + assertEquals(org.bukkit.Material.CAKE, cake.getType()); + assertFalse(cake.getItemMeta().hasTooltipStyle()); + } + @Test void pluginDisableIsClean() { server.getPluginManager().disablePlugin(plugin); diff --git a/plugin/src/test/java/ru/abstractmenus/menu/TestInventoryTitles.java b/plugin/src/test/java/ru/abstractmenus/menu/TestInventoryTitles.java new file mode 100644 index 0000000..a51a410 --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/menu/TestInventoryTitles.java @@ -0,0 +1,89 @@ +package ru.abstractmenus.menu; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.inventory.InventoryMock; +import ru.abstractmenus.api.AbstractMenusApi; +import ru.abstractmenus.api.text.Colors; +import ru.abstractmenus.util.MiniMessageUtil; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TestInventoryTitles { + + private Player player; + + @BeforeEach + void startServer() { + var server = MockBukkit.mock(); + var api = mock(AbstractMenusApi.class, RETURNS_DEEP_STUBS); + when(api.providers().placeholders().resolve().replace(any(), anyString())) + .thenAnswer(call -> call.getArgument(1).replace("%title%", "Menu").replace("%brand%", "R&D")); + server.getServicesManager().register(AbstractMenusApi.class, api, MockBukkit.createMockPlugin(), ServicePriority.Normal); + player = server.addPlayer(); + Colors.init(false); + } + + @AfterEach + void stopServer() { + MiniMessageUtil.init(false); + MockBukkit.unmock(); + } + + @Test + void legacyTitleProducesComponentWithAndWithoutMiniMessage() { + for (boolean miniMessage : new boolean[]{false, true}) { + MiniMessageUtil.init(miniMessage); + assertEquals(Component.text("Menu", NamedTextColor.GREEN, TextDecoration.BOLD), titleOf("&a&l%title%", null)); + } + } + + @Test + void miniMessageTitleRetainsComponentEventsAndFormattingForTypedInventory() { + MiniMessageUtil.init(true); + Component expected = Component.text("Menu", NamedTextColor.GOLD, TextDecoration.BOLD) + .hoverEvent(Component.text("Details")); + assertEquals(expected, titleOf("%title%", InventoryType.HOPPER)); + } + + @Test + void hexTitlesPreserveRgbColors() { + Component expected = Component.text("Menu", TextColor.color(0x12abef)); + for (boolean miniMessage : new boolean[]{false, true}) { + MiniMessageUtil.init(miniMessage); + assertEquals(expected, titleOf("&x&1&2&a&b&e&f%title%", null)); + } + MiniMessageUtil.init(true); + assertEquals(expected, titleOf("<#12abef>%title%", null)); + } + + @Test + void placeholderAmpersandsRemainLiteral() { + for (boolean miniMessage : new boolean[]{false, true}) { + MiniMessageUtil.init(miniMessage); + for (InventoryType type : new InventoryType[]{null, InventoryType.HOPPER}) { + assertEquals(Component.text("R&D"), titleOf("%brand%", type)); + assertEquals(Component.text("R&D", NamedTextColor.GREEN), titleOf("&a%brand%", type)); + assertEquals(Component.text("Menu", NamedTextColor.GREEN), titleOf("\u00a7a%title%", type)); + } + } + } + + private Component titleOf(String title, InventoryType type) { + var menu = new SimpleMenu(title, 9); + menu.setType(type); + menu.createInventory(player, menu); + return ((InventoryMock) menu.getInventory()).getCustomTitle(); + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/menu/TestMenuLifecycleRegressions.java b/plugin/src/test/java/ru/abstractmenus/menu/TestMenuLifecycleRegressions.java new file mode 100644 index 0000000..b2470ad --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/menu/TestMenuLifecycleRegressions.java @@ -0,0 +1,359 @@ +package ru.abstractmenus.menu; + +import com.tcoded.folialib.FoliaLib; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockbukkit.mockbukkit.inventory.InventoryMock; +import ru.abstractmenus.MainConfig; +import ru.abstractmenus.api.AbstractMenusApi; +import ru.abstractmenus.api.Catalog; +import ru.abstractmenus.api.ValueExtractor; +import ru.abstractmenus.api.inventory.*; +import ru.abstractmenus.api.inventory.slot.SlotIndex; +import ru.abstractmenus.api.text.Colors; +import ru.abstractmenus.data.Actions; +import ru.abstractmenus.datatype.TypeSlot; +import ru.abstractmenus.listeners.InventoryListener; +import ru.abstractmenus.menu.animated.*; +import ru.abstractmenus.menu.generated.*; +import ru.abstractmenus.menu.item.MenuItem; +import ru.abstractmenus.services.MenuManager; +import ru.abstractmenus.util.MiniMessageUtil; +import ru.abstractmenus.util.bukkit.BukkitTasks; +import java.lang.reflect.*; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TestMenuLifecycleRegressions { + ServerMock server; + org.bukkit.plugin.Plugin plugin; + Player player; + MenuManager manager; + + @BeforeEach void setup() { + server = MockBukkit.mock(); + plugin = MockBukkit.createMockPlugin(); + ru.abstractmenus.api.Logger.set(plugin.getLogger()); + var api = mock(AbstractMenusApi.class, RETURNS_DEEP_STUBS); + when(api.providers().placeholders().resolve().replace(any(), anyString())) + .thenAnswer(c -> c.getArgument(1)); + server.getServicesManager().register(AbstractMenusApi.class, api, plugin, ServicePriority.Normal); + player = server.addPlayer("Alice"); + Colors.init(false); + MiniMessageUtil.init(false); + MainConfig config = mock(MainConfig.class); + when(config.getMenusFolder()).thenReturn(Path.of("/private/tmp/menu-audit-unused")); + manager = new MenuManager(plugin, config); + BukkitTasks.setPlugin(plugin); + BukkitTasks.setFoliaLib(mock(FoliaLib.class)); + ru.abstractmenus.util.bukkit.Events.setPlugin(plugin); + server.getPluginManager().registerEvents(new InventoryListener(), plugin); + } + @AfterEach void teardown() { + try { MockBukkit.unmock(); } + finally { BukkitTasks.setPlugin(null); BukkitTasks.setFoliaLib(null); ru.abstractmenus.util.bukkit.Events.setPlugin(null); } + } + private static Actions count(AtomicInteger counter) { + var a = new Actions(); + a.add((p,m,i) -> counter.incrementAndGet()); + return a; + } + private static void set(Object obj, Class owner, String field, Object value) throws Exception { + Field f = owner.getDeclaredField(field); f.setAccessible(true); f.set(obj,value); + } + private GeneratedMenu generated(Catalog catalog) { + var m = new GeneratedMenu("Generated",9); + var item = new MenuItem(); item.setClickCooldown(0); + m.setMatrix(new Matrix(new LinkedHashMap<>(Map.of(0,item)))); + m.setCatalog(catalog); + return m; + } + private static Catalog catalog(java.util.function.Function> fn) { + return new Catalog<>() { + public Collection snapshot(Player p, Menu m) { return fn.apply(p); } + public ValueExtractor extractor() { return (o,k) -> o.toString(); } + }; + } + @Test void generatedContextsAreIsolated() { + Player bob = server.addPlayer("Bob"); + GeneratedMenu template = generated(catalog(p -> List.of("Alice-target","Bob-target"))); + manager.openMenu(player,template); + GeneratedMenu aliceMenu = (GeneratedMenu) manager.getOpenedMenu(player); + assertEquals("Alice-target",aliceMenu.getCurrentObject()); + manager.openMenu(bob,template); + ((GeneratedMenu) manager.getOpenedMenu(bob)).nextPage(bob,1); + aliceMenu.click(0,player,ClickType.LEFT); + assertEquals("Alice-target",aliceMenu.getCurrentObject()); + } + @Test void switchingMenusClosesOldInstanceOnce() { + var closed = new AtomicInteger(); + var a = new SimpleMenu("A",9); a.setCloseActions(count(closed)); + manager.openMenu(player,a); + manager.openMenu(player,new SimpleMenu("B",9)); + server.getScheduler().performTicks(5); + assertEquals(1,closed.get()); + } + @Test void oldCloseDoesNotUnregisterNewMenu() { + var closedB = new AtomicInteger(); + manager.openMenu(player,new SimpleMenu("A",9)); + player.closeInventory(); + var b = new SimpleMenu("B",9); b.setCloseActions(count(closedB)); + manager.openMenu(player,b); + Menu visibleB = manager.getOpenedMenu(player); + server.getScheduler().performTicks(5); + assertSame(visibleB.getInventory(),player.getOpenInventory().getTopInventory()); + assertSame(visibleB,manager.getOpenedMenu(player)); + assertEquals(0,closedB.get()); + } + @Test void emptyCatalogCanNavigateAndRepopulate() { + var rows = new ArrayList(); + var template = generated(catalog(p -> rows)); + manager.openMenu(player,template); + var opened = (GeneratedMenu) manager.getOpenedMenu(player); + assertEquals(0,opened.getPages()); + assertDoesNotThrow(() -> opened.nextPage(player,1)); + assertEquals(0,opened.getPage()); + rows.add("new entry"); + assertDoesNotThrow(() -> opened.refresh(player)); + assertEquals(1,opened.getPages()); + assertNotNull(opened.getItem(0)); + } + @Test void catalogShrinkClampsPage() { + var rows = new ArrayList<>(List.of("first","second","third")); + manager.openMenu(player,generated(catalog(p -> rows))); + var opened = (GeneratedMenu) manager.getOpenedMenu(player); + opened.nextPage(player,2); + assertEquals(2,opened.getPage()); + rows.remove(2); rows.remove(1); + opened.refresh(player); + assertEquals(1,opened.getPages()); + assertEquals(0,opened.getPage()); + assertNotNull(opened.getItem(0)); + } + @Test void animationRefreshReplaysCompletion() throws Exception { + var ended = new AtomicInteger(); + var ctor = Frame.class.getDeclaredConstructor(long.class,boolean.class); + ctor.setAccessible(true); + Frame frame = ctor.newInstance(0L,true); + frame.setItems(List.of()); + var template = new AnimatedMenu("Animated",9); + template.setFrames(List.of(frame)); + template.setAnimEndActions(count(ended)); + manager.openMenu(player,template); + var opened = (AnimatedMenu) manager.getOpenedMenu(player); + opened.update(player); opened.update(player); + assertEquals(1,ended.get()); + opened.refresh(player); + opened.update(player); opened.update(player); + assertEquals(2,ended.get()); + } + @Test void refreshPreservesDepositsOverConfiguredItems() { + var template = new SimpleMenu("Deposit",9); + template.setDraggableSlots(new TypeSlot(new SlotIndex(0))); + manager.openMenu(player,template); + var opened = (SimpleMenu) manager.getOpenedMenu(player); + opened.placeItem(player,0,new ItemStack(Material.DIAMOND,4)); + opened.getInventory().setItem(0,new ItemStack(Material.DIAMOND,4)); + var configured = new MenuItem(); + configured.setSlot(new TypeSlot(new SlotIndex(0))); + opened.setItems(List.of(configured)); + opened.refresh(player); + assertEquals(new ItemStack(Material.DIAMOND,4),opened.getInventory().getItem(0)); + assertEquals(new ItemStack(Material.DIAMOND,4),opened.getPlacedItem(0)); + assertNull(opened.getItem(0)); + } + + @Test void animationFramesPreserveDeposits() throws Exception { + var ctor = Frame.class.getDeclaredConstructor(long.class,boolean.class); + ctor.setAccessible(true); + var item = new MenuItem(); item.setSlot(new TypeSlot(new SlotIndex(0))); + Frame frame = ctor.newInstance(0L,true); frame.setItems(List.of(item)); + var template = new AnimatedMenu("Deposit",9); + template.setFrames(List.of(frame)); + template.setDraggableSlots(new TypeSlot(new SlotIndex(0))); + manager.openMenu(player,template); + var opened = (AnimatedMenu)manager.getOpenedMenu(player); + opened.placeItem(player,0,new ItemStack(Material.DIAMOND,4)); + opened.getInventory().setItem(0,new ItemStack(Material.DIAMOND,4)); + opened.update(player); + assertEquals(new ItemStack(Material.DIAMOND,4),opened.getInventory().getItem(0)); + assertNull(opened.getItem(0)); + opened.refresh(player); + assertEquals(new ItemStack(Material.DIAMOND,4),opened.getInventory().getItem(0)); + } + + private void defaultPlaceholders(Map values) throws Exception { + var variables = mock(ru.abstractmenus.variables.VariableManagerImpl.class); + when(variables.getGlobal(anyString())).thenAnswer(c -> { + String value = values.get(c.getArgument(0)); + if (value == null) return null; + var v = mock(ru.abstractmenus.api.variables.Var.class); + when(v.value()).thenReturn(value); + return v; + }); + set(null,ru.abstractmenus.variables.VariableManagerImpl.class,"instance",variables); + var handler = new ru.abstractmenus.handlers.placeholder.PlaceholderDefaultHandler(); + handler.registerAll(); + when(AbstractMenusApi.get().providers().placeholders().resolve()).thenReturn(handler); + } + @Test void hotReplacementUnregistersOldActivator() throws Exception { + var old = new SimpleMenu("Old",9); + var block = player.getWorld().getBlockAt(10,64,10); + block.setType(Material.STONE); + var ctor = ru.abstractmenus.data.activators.OpenClickBlock.class.getDeclaredConstructor(List.class); + ctor.setAccessible(true); + var activator = ctor.newInstance(List.of(new ru.abstractmenus.datatype.TypeLocation(block.getLocation()))); + old.setActivators(List.of(activator)); + manager.addMenu("shop",old); + var locked = new SimpleMenu("New locked",9); + locked.setOpenRules((p,m,i) -> false); + manager.addMenu("shop",locked); + doAnswer(c -> { manager.openMenu(c.getArgument(0),c.getArgument(1),c.getArgument(2),c.getArgument(3)); return null; }) + .when(AbstractMenusApi.get()).openMenu(any(),any(),any(),any()); + var event = new org.bukkit.event.player.PlayerInteractEvent(player, + org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK,null,block,org.bukkit.block.BlockFace.UP, + org.bukkit.inventory.EquipmentSlot.HAND); + server.getPluginManager().callEvent(event); + assertNull(manager.getOpenedMenu(player)); + assertSame(locked,manager.getMenu("shop")); + } + @Test void dynamicLowercaseDyeMatchesLiteral() throws Exception { + defaultPlaceholders(Map.of("color","red")); + org.bukkit.Color actual = new ru.abstractmenus.datatype.TypeColor("%var_get:color%").getColor(player,null); + assertEquals(org.bukkit.DyeColor.RED.getColor(),actual); + } + @Test void generatedStaticPlaceholdersSeeCurrentSnapshot() throws Exception { + defaultPlaceholders(Map.of()); + var template = generated(catalog(p -> List.of("one","two"))); + var label = new MenuItem(); label.setSlot(new TypeSlot(new SlotIndex(8))); + var ctor = ru.abstractmenus.data.properties.PropName.class.getDeclaredConstructor(String.class); + ctor.setAccessible(true); + label.addProperty("name",ctor.newInstance("Entries: %ctg_elements%")); + template.setItems(List.of(label)); + manager.openMenu(player,template); + var opened = (GeneratedMenu)manager.getOpenedMenu(player); + assertNotNull(opened.getItem(8)); + assertEquals("Entries: 2",opened.getInventory().getItem(8).getItemMeta().getDisplayName()); + assertEquals(2,opened.getSnapshot().size()); + opened.refresh(player); + assertNotNull(opened.getItem(8)); + } + @Test void typedInventoryAllowsBottomOnlyDrags() { + var template = new SimpleMenu("Hopper",9); template.setType(InventoryType.HOPPER); + template.setDraggableSlots(new TypeSlot(new SlotIndex(0))); + manager.openMenu(player,template); + var opened = manager.getOpenedMenu(player); + assertEquals(9,opened.getSize()); assertEquals(5,opened.getInventory().getSize()); + var event = new org.bukkit.event.inventory.InventoryDragEvent(player.getOpenInventory(), + new ItemStack(Material.DIAMOND,1),new ItemStack(Material.DIAMOND,2),false, + Map.of(5,new ItemStack(Material.DIAMOND,1))); + assertTrue(event.getRawSlots().stream().allMatch(i -> i >= opened.getInventory().getSize())); + server.getPluginManager().callEvent(event); + assertFalse(event.isCancelled()); + } + @Test void cancelledTransfersNeverBecomeDeposits() { + var template = new SimpleMenu("Deposit",9); + template.setDraggableSlots(new TypeSlot(new SlotIndex(0))); + manager.openMenu(player,template); + var menu = (AbstractMenu)manager.getOpenedMenu(player); + player.setItemOnCursor(new ItemStack(Material.DIAMOND,10)); + var listener = new InventoryListener(); + for (boolean cancelledEarly : new boolean[]{true,false}) { + var event = new org.bukkit.event.inventory.InventoryClickEvent(player.getOpenInventory(), + org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,ClickType.LEFT, + org.bukkit.event.inventory.InventoryAction.PLACE_ALL); + event.setCancelled(cancelledEarly); + listener.onInventoryClick(event); + listener.reconcileClick(event); + event.setCancelled(true); + server.getScheduler().performTicks(2); + assertTrue(menu.getPlacedItem(0).getType().isAir()); + } + var drag = new org.bukkit.event.inventory.InventoryDragEvent(player.getOpenInventory(), + new ItemStack(Material.DIAMOND,9),new ItemStack(Material.DIAMOND,10),false, + Map.of(0,new ItemStack(Material.DIAMOND))); + drag.setCancelled(true); + listener.onDrag(drag); + listener.reconcileDrag(drag); + listener.onInventoryClose(new org.bukkit.event.inventory.InventoryCloseEvent(player.getOpenInventory())); + assertEquals(10,player.getItemOnCursor().getAmount()); + assertTrue(player.getWorld().getEntitiesByClass(org.bukkit.entity.Item.class).isEmpty()); + } + + @Test void acceptedTransfersTrackPhysicalAmountsAndCloseReturnsOnce() { + var template = new SimpleMenu("Deposit",9); + template.setDraggableSlots(new TypeSlot(new SlotIndex(0))); + manager.openMenu(player,template); + var menu = (AbstractMenu)manager.getOpenedMenu(player); + var listener = new InventoryListener(); + player.setItemOnCursor(new ItemStack(Material.DIAMOND,10)); + var place = new org.bukkit.event.inventory.InventoryClickEvent(player.getOpenInventory(), + org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,ClickType.LEFT, + org.bukkit.event.inventory.InventoryAction.PLACE_SOME); + listener.onInventoryClick(place); + listener.reconcileClick(place); + menu.getInventory().setItem(0,new ItemStack(Material.DIAMOND,6)); + player.setItemOnCursor(new ItemStack(Material.DIAMOND,4)); + server.getScheduler().performTicks(2); + assertEquals(6,menu.getPlacedItem(0).getAmount()); + var take = new org.bukkit.event.inventory.InventoryClickEvent(player.getOpenInventory(), + org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,ClickType.RIGHT, + org.bukkit.event.inventory.InventoryAction.PICKUP_HALF); + listener.onInventoryClick(take); + listener.reconcileClick(take); + menu.getInventory().setItem(0,new ItemStack(Material.DIAMOND,3)); + server.getScheduler().performTicks(2); + assertEquals(3,menu.getPlacedItem(0).getAmount()); + player.closeInventory(); + menu.close(player,false); + assertEquals(3,player.getWorld().getEntitiesByClass(org.bukkit.entity.Item.class).stream() + .mapToInt(item -> item.getItemStack().getAmount()).sum()); + } + @Test void unloadOnOwningThreadReturnsDepositsSynchronously() { + var closed = new AtomicInteger(); + var template = new SimpleMenu("Deposit",9); + template.setDraggableSlots(new TypeSlot(new SlotIndex(0))); + template.setCloseActions(count(closed)); + manager.openMenu(player,template); + var menu = (AbstractMenu)manager.getOpenedMenu(player); + menu.placeItem(player,0,new ItemStack(Material.DIAMOND,4)); + menu.getInventory().setItem(0,new ItemStack(Material.DIAMOND,4)); + assertTrue(org.bukkit.Bukkit.isOwnedByCurrentRegion(player)); + manager.unloadAll(); + assertEquals(1,closed.get()); + assertNull(manager.getOpenedMenu(player)); + assertNotSame(menu.getInventory(),player.getOpenInventory().getTopInventory()); + assertEquals(4,player.getWorld().getEntitiesByClass(org.bukkit.entity.Item.class).stream() + .mapToInt(item -> item.getItemStack().getAmount()).sum()); + server.getScheduler().performTicks(2); + assertEquals(1,closed.get()); + } + + @Test void disablingPluginClosesWithoutScheduling() { + var disabledPlugin = mock(org.bukkit.plugin.Plugin.class); + var config = mock(MainConfig.class); + when(config.getMenusFolder()).thenReturn(Path.of("/private/tmp/menu-audit-unused")); + var disablingManager = new MenuManager(disabledPlugin,config); + var menu = mock(Menu.class); + disablingManager.setOpenedMenu(player,menu); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class,CALLS_REAL_METHODS)) { + bukkit.when(() -> org.bukkit.Bukkit.isOwnedByCurrentRegion(player)).thenReturn(false); + disablingManager.unloadAll(); + verify(menu).close(player,true); + } + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/util/bukkit/TestSkulls.java b/plugin/src/test/java/ru/abstractmenus/util/bukkit/TestSkulls.java new file mode 100644 index 0000000..f787a8a --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/util/bukkit/TestSkulls.java @@ -0,0 +1,83 @@ +package ru.abstractmenus.util.bukkit; + +import com.destroystokyo.paper.profile.ProfileProperty; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.SkullMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import ru.abstractmenus.listeners.PlayerListener; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TestSkulls { + + private ServerMock server; + + @BeforeEach + void startServer() { + server = MockBukkit.mock(new ServerMock() { + @Override + public OfflinePlayer getOfflinePlayer(String name) { + throw new AssertionError("Head creation must not perform a blocking offline player lookup"); + } + }); + Skulls.clearCache(); + } + + @AfterEach + void stopServer() { + Skulls.clearCache(); + MockBukkit.unmock(); + } + + @Test + void unknownOfflineOwnerProducesNameOnlyHeadWithoutMojangLookup() { + try (var mojang = mockStatic(MojangApi.class)) { + ItemStack head = Skulls.getPlayerSkull("OfflineSkin"); + + assertNotNull(head); + assertEquals(Material.PLAYER_HEAD, head.getType()); + var profile = ((SkullMeta) head.getItemMeta()).getPlayerProfile(); + assertNotNull(profile); + assertEquals("OfflineSkin", profile.getName()); + assertNull(profile.getId()); + assertTrue(profile.getProperties().isEmpty()); + mojang.verifyNoInteractions(); + } + } + + @Test + void joinInvalidatesCaseInsensitiveCacheAndPreservesOnlineSkin() { + ItemStack offline = Skulls.getPlayerSkull("OfflineSkin"); + offline.setAmount(5); + assertEquals(1, Skulls.getPlayerSkull("offlineskin").getAmount()); + + var player = server.addPlayer("OfflineSkin"); + var profile = player.getPlayerProfile(); + var texture = new ProfileProperty("textures", "custom-skin", "signature"); + profile.setProperty(texture); + player.setPlayerProfile(profile); + new PlayerListener().onPlayerJoin(new org.bukkit.event.player.PlayerJoinEvent(player, (net.kyori.adventure.text.Component) null)); + + ItemStack online = Skulls.getPlayerSkull("OFFLINESKIN"); + assertNotNull(online); + var skullProfile = ((SkullMeta) online.getItemMeta()).getPlayerProfile(); + assertNotNull(skullProfile); + assertEquals(player.getUniqueId(), skullProfile.getId()); + assertTrue(skullProfile.getProperties().contains(texture)); + } + + @Test + void invalidOwnerKeepsDefaultHeadPath() { + assertNull(Skulls.getPlayerSkull(null)); + assertNull(Skulls.getPlayerSkull("")); + assertNull(Skulls.getPlayerSkull("%missing owner%")); + assertNull(Skulls.getPlayerSkull("longerThanSixteenCharacters")); + } +} diff --git a/plugin/src/test/java/ru/abstractmenus/variables/TestVariablePersistence.java b/plugin/src/test/java/ru/abstractmenus/variables/TestVariablePersistence.java new file mode 100644 index 0000000..55f820f --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/variables/TestVariablePersistence.java @@ -0,0 +1,93 @@ +package ru.abstractmenus.variables; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import ru.abstractmenus.MainConfig; +import ru.abstractmenus.util.bukkit.BukkitTasks; +import ru.abstractmenus.util.bukkit.TaskHandle; + +import java.nio.file.Path; +import java.sql.DriverManager; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TestVariablePersistence { + @TempDir Path folder; + private VariableManagerImpl manager; + + @BeforeEach + void setUp() { + ru.abstractmenus.api.Logger.set(java.util.logging.Logger.getLogger("variables-test")); + var config = mock(MainConfig.class); + when(config.isUseVariables()).thenReturn(true); + when(config.getDbFolder()).thenReturn(folder); + try (var tasks = mockStatic(BukkitTasks.class)) { + tasks.when(() -> BukkitTasks.runTaskTimerAsync(any(),anyLong(),anyLong())).thenReturn(TaskHandle.NOOP); + manager = new VariableManagerImpl(config); + } + } + + @AfterEach + void tearDown() { manager.shutdown(); } + + private String diskValue(String name) throws Exception { + try (var db = DriverManager.getConnection("jdbc:sqlite:" + folder.resolve("variables.db")); + var statement = db.prepareStatement("SELECT value FROM variables WHERE key=?")) { + statement.setString(1,"#global:" + name); + try (var result = statement.executeQuery()) { return result.next() ? result.getString(1) : null; } + } + } + + @Test + void writesPersistInSubmissionOrder() throws Exception { + for (int i=0; i<100; i++) manager.saveGlobal(new VarImpl("score",String.valueOf(i),0),true); + manager.deleteGlobal("score"); + manager.saveGlobal(new VarImpl("score","last",0),true); + manager.shutdown(); + assertEquals("last",diskValue("score")); + } + + @Test + void savesReturnBeforeIoAndShutdownDrainsAcceptedWrites() throws Exception { + var daoField = VariableManagerImpl.class.getDeclaredField("dao"); + daoField.setAccessible(true); + var writerField = VariablesDao.class.getDeclaredField("writer"); + writerField.setAccessible(true); + var writer = (ExecutorService)writerField.get(daoField.get(manager)); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + writer.execute(() -> { + entered.countDown(); + try { release.await(5,TimeUnit.SECONDS); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(1,TimeUnit.SECONDS)); + try { + assertTimeout(java.time.Duration.ofSeconds(1), + () -> manager.saveGlobal(new VarImpl("reward","saved",0),true)); + var closing = CompletableFuture.runAsync(manager::shutdown); + assertThrows(TimeoutException.class,() -> closing.get(50,TimeUnit.MILLISECONDS)); + release.countDown(); + closing.get(2,TimeUnit.SECONDS); + assertEquals("saved",diskValue("reward")); + manager.saveGlobal(new VarImpl("reward","too late",0),true); + assertEquals("saved",manager.getGlobal("reward").value()); + assertEquals("saved",diskValue("reward")); + } finally { release.countDown(); } + } + + @Test + void numericChangesPreserveGlobalAndPersonalExpiry() { + long expiry = System.currentTimeMillis()+600000; + manager.saveGlobal(new VarImpl("attempts","1",expiry),true); + manager.savePersonal("Alice",new VarImpl("attempts","1",expiry),true); + manager.modifyNumericGlobal("attempts",value -> value+1); + manager.modifyNumericPersonal("Alice","attempts",value -> value+1); + assertEquals(expiry,manager.getGlobal("attempts").expiry()); + assertEquals(expiry,manager.getPersonal("Alice","attempts").expiry()); + assertEquals(2,manager.getGlobal("attempts").intValue()); + } +}