From 55792ee067bd5fb136ac5578642e89305663b47b Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 25 Feb 2026 13:23:41 -0400 Subject: [PATCH 01/12] Add Nexus deploy workflow and Maven publishing Add a GitHub Actions workflow to deploy artifacts to Negative Games Nexus for release and snapshot branches. The workflow sets up JDK 21 and Gradle, injects Nexus credentials into ~/.gradle/gradle.properties from secrets, adjusts module apiVersion for release/snapshot branches, and runs ./gradlew publish with an isRelease flag. Update root build.gradle.kts to apply maven-publish and configure a nexus repository that selects snapshots or releases based on the isRelease property and reads credentials from gradle.properties. Add maven-publish and shadow publishing configuration to common and paper modules: set module ids, domain, apiVersion, Java 21 toolchain, disable the plain jar, configure shadowJar as the published artifact, and include POM metadata (license, developer, url). --- .github/workflows/deploy.yml | 60 ++++++++++++++++++++++++++++++++++++ build.gradle.kts | 25 +++++++++++++++ common/build.gradle.kts | 56 +++++++++++++++++++++++++++++++++ paper/build.gradle.kts | 56 +++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0212e8b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,60 @@ +name: Deploy to Negative Games Nexus (Gradle) + +on: + push: + branches: + - release + - snapshot + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Configure Gradle / Nexus credentials + run: | + mkdir -p ~/.gradle + cat << 'EOF' > ~/.gradle/gradle.properties + nexusUsername=${{ secrets.NEXUS_USERNAME }} + nexusPassword=${{ secrets.NEXUS_PASSWORD }} + nexusSnapshotsUrl=https://repo.negative.games/repository/maven-snapshots + nexusReleasesUrl=https://repo.negative.games/repository/maven-releases + EOF + + # Modify version for release branch (remove -SNAPSHOT) + - name: Modify version for release + if: github.ref == 'refs/heads/release' + run: | + find . -name "build.gradle" -type f -exec sed -i "s/\(def apiVersion = '[^']*\)-SNAPSHOT'/\1'/" {} + + + # Modify version for snapshot branch (add -SNAPSHOT if not present) + - name: Modify version for snapshot + if: github.ref == 'refs/heads/snapshot' + run: | + find . -name "build.gradle" -type f -exec sed -i "/-SNAPSHOT'/! s/\(def apiVersion = '[^']*\)'/\1-SNAPSHOT'/" {} + + + # Make gradlew executable + - name: Make gradlew executable + run: chmod +x ./gradlew + + # Deploy to RELEASES on release branch + - name: Build and deploy release + if: github.ref == 'refs/heads/release' + run: ./gradlew clean publish -PisRelease=true + + # Deploy to SNAPSHOTS on snapshot branch + - name: Build and deploy snapshot + if: github.ref == 'refs/heads/snapshot' + run: ./gradlew clean publish -PisRelease=false \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 76f114a..1836acd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("java") + id("maven-publish") } subprojects { @@ -23,3 +24,27 @@ subprojects { tasks.withType() { enabled = false } + + +subprojects { + plugins.withId("maven-publish") { + configure { + repositories { + maven { + name = "nexus" + + val snapshotsUrl = findProperty("nexusSnapshotsUrl") as String? ?: "https://repo.negative.games/repository/maven-snapshots" + val releasesUrl = findProperty("nexusReleasesUrl") as String? ?: "https://repo.negative.games/repository/maven-releases" + + val isRelease = (findProperty("isRelease") == "true") + url = uri(if (isRelease) releasesUrl else snapshotsUrl) + + credentials { + username = findProperty("nexusUsername") as String? + password = findProperty("nexusPassword") as String? + } + } + } + } + } +} \ No newline at end of file diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 1ed51f2..36d3ef8 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -1,7 +1,13 @@ plugins { id("java") + id("maven-publish") + id("com.gradleup.shadow") version("9.2.2") } +var id = "plugin-engine-common" +var domain = "games.negative.engine" +var apiVersion = "1.0.0" + repositories { mavenCentral() } @@ -33,4 +39,54 @@ dependencies { // Lombok compileOnly("org.projectlombok:lombok:1.18.32") annotationProcessor("org.projectlombok:lombok:1.18.32") +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +tasks.jar { + enabled = false +} + +tasks.shadowJar { + archiveBaseName.set(id) + archiveVersion.set(apiVersion) + archiveClassifier.set("") +} + +publishing { + publications { + create("mavenJava") { + artifact(tasks.shadowJar) { + builtBy(tasks.shadowJar) + } + + groupId = domain + artifactId = id + version = apiVersion + + pom { + name.set(id) + description.set(project.description) + url.set("https://github.com/negative-games/plugin-engine") + + licenses { + license { + name.set("The MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("ericlmao") + name.set("Eric") + } + } + } + } + } } \ No newline at end of file diff --git a/paper/build.gradle.kts b/paper/build.gradle.kts index fcdf873..49a3616 100644 --- a/paper/build.gradle.kts +++ b/paper/build.gradle.kts @@ -1,7 +1,13 @@ plugins { id("java") + id("maven-publish") + id("com.gradleup.shadow") version("9.2.2") } +var id = "plugin-engine-paper" +var domain = "games.negative.engine" +var apiVersion = "1.0.0" + repositories { mavenCentral() } @@ -33,4 +39,54 @@ dependencies { // Lombok compileOnly("org.projectlombok:lombok:1.18.32") annotationProcessor("org.projectlombok:lombok:1.18.32") +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +tasks.jar { + enabled = false +} + +tasks.shadowJar { + archiveBaseName.set(id) + archiveVersion.set(apiVersion) + archiveClassifier.set("") +} + +publishing { + publications { + create("mavenJava") { + artifact(tasks.shadowJar) { + builtBy(tasks.shadowJar) + } + + groupId = domain + artifactId = id + version = apiVersion + + pom { + name.set(id) + description.set(project.description) + url.set("https://github.com/negative-games/plugin-engine") + + licenses { + license { + name.set("The MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("ericlmao") + name.set("Eric") + } + } + } + } + } } \ No newline at end of file From 2999e9bd3fb4182067b80e3c332be7751e401b21 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 25 Feb 2026 13:45:04 -0400 Subject: [PATCH 02/12] Update deploy workflow for Kotlin DSL builds Switch sed targets from build.gradle to build.gradle.kts and update the regexes to match Kotlin DSL variable declarations (var apiVersion = "...") for both release and snapshot branches. This ensures version bump/removal works for build.gradle.kts files. Also add a missing newline at end of file. --- .github/workflows/deploy.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0212e8b..2f26a94 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,13 +37,13 @@ jobs: - name: Modify version for release if: github.ref == 'refs/heads/release' run: | - find . -name "build.gradle" -type f -exec sed -i "s/\(def apiVersion = '[^']*\)-SNAPSHOT'/\1'/" {} + + find . -name "build.gradle.kts" -type f -exec sed -i "s/\\(var apiVersion = \\\"[^\\\"]*\\)-SNAPSHOT\\\"/\\1\\\"/" {} + # Modify version for snapshot branch (add -SNAPSHOT if not present) - name: Modify version for snapshot if: github.ref == 'refs/heads/snapshot' run: | - find . -name "build.gradle" -type f -exec sed -i "/-SNAPSHOT'/! s/\(def apiVersion = '[^']*\)'/\1-SNAPSHOT'/" {} + + find . -name "build.gradle.kts" -type f -exec sed -i "/var apiVersion = \\\".*-SNAPSHOT\\\"/! s/\\(var apiVersion = \\\"[^\\\"]*\\)\\\"/\\1-SNAPSHOT\\\"/" {} + # Make gradlew executable - name: Make gradlew executable @@ -57,4 +57,4 @@ jobs: # Deploy to SNAPSHOTS on snapshot branch - name: Build and deploy snapshot if: github.ref == 'refs/heads/snapshot' - run: ./gradlew clean publish -PisRelease=false \ No newline at end of file + run: ./gradlew clean publish -PisRelease=false From e0e106177228d8b835018a71e70bd33ab4fa1c3e Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 25 Feb 2026 13:48:46 -0400 Subject: [PATCH 03/12] Remove disabling of tasks.jar Delete the tasks.jar { enabled = false } block from common/build.gradle.kts so the default jar task is no longer disabled. This restores normal jar packaging behavior while keeping the shadowJar configuration (archiveBaseName and archiveVersion) intact. --- common/build.gradle.kts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 36d3ef8..5435a1f 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -47,10 +47,6 @@ java { } } -tasks.jar { - enabled = false -} - tasks.shadowJar { archiveBaseName.set(id) archiveVersion.set(apiVersion) From 1076ab05c6e6781da267ef1127c848c27b5d981b Mon Sep 17 00:00:00 2001 From: Eric <60104846+ericlmao@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:58:21 -0400 Subject: [PATCH 04/12] Add README and PaperCommand; update registry (#2) Add project README documenting modules, usage, and APIs. Introduce PaperCommand interface (CloudCommand specialized for CommandSourceStack) and update PaperCommandRegistry to discover/register PaperCommand beans (remove unused CloudCommand import). This aligns command discovery with the Paper-specific command type and adds library documentation. --- README.md | 245 ++++++++++++++++++ .../engine/paper/command/PaperCommand.java | 8 + .../paper/command/PaperCommandRegistry.java | 3 +- 3 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 README.md create mode 100644 paper/src/main/java/games/negative/engine/paper/command/PaperCommand.java diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd11161 --- /dev/null +++ b/README.md @@ -0,0 +1,245 @@ +# plugin-engine + +A Java 21 library for building Minecraft Paper plugins with reusable building blocks for commands, scheduling, GUIs, messaging, configuration, and utility helpers. + +## What this library provides + +`plugin-engine` is split into two modules: + +| Module | Artifact | Purpose | +| --- | --- | --- | +| common | `games.negative.engine:plugin-engine-common` | Platform-agnostic APIs and helpers (configuration, messages, command abstractions, utilities). | +| paper | `games.negative.engine:plugin-engine-paper` | Paper-specific implementations (plugin base class, schedulers, command registration, GUI framework, item builder, jobs). | + +## Compatibility + +- Java 21 +- Paper API `1.21.8-R0.1-SNAPSHOT` (for the `paper` module) + +## Installation + +Add the Negative Games Maven repository: + +### Gradle (Kotlin DSL) + +```kotlin +repositories { + maven("https://repo.negative.games/repository/maven-releases/") + maven("https://repo.negative.games/repository/maven-snapshots/") +} +``` + +Then add dependencies: + +```kotlin +dependencies { + implementation("games.negative.engine:plugin-engine-paper:1.0.0") + // or: implementation("games.negative.engine:plugin-engine-common:1.0.0") +} +``` + +### Maven + +```xml + + + negative-games-releases + https://repo.negative.games/repository/maven-releases/ + + + negative-games-snapshots + https://repo.negative.games/repository/maven-snapshots/ + + +``` + +```xml + + + games.negative.engine + plugin-engine-paper + 1.0.0 + + +``` + +Use `-SNAPSHOT` versions when consuming snapshot builds. + +## Quick start (Paper plugins) + +### 1) Extend `PaperPlugin` + +```java +package com.example; + +import games.negative.engine.paper.PaperPlugin; + +public final class ExamplePlugin extends PaperPlugin { +} +``` + +`PaperPlugin` initializes scheduler and MiniMessage utilities for you and exposes the shared `Plugin` contract (`directory()`, `fetchBeans(...)`). + +### 2) Optional: custom library loader + +If you need extra runtime libraries, extend `PaperPluginLoader`: + +```java +package com.example; + +import games.negative.engine.paper.loader.PaperPluginLoader; +import org.eclipse.aether.graph.Dependency; +import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver; + +public final class ExamplePluginLoader extends PaperPluginLoader { + @Override + public void addLibraries(MavenLibraryResolver resolver) { + Dependency dep = dependency("com.example:example-lib:1.2.3"); + resolver.addDependency(dep); + } +} +``` + +## Commands + +`paper` includes `PaperCommandRegistry`, which auto-discovers Spring beans implementing `CloudCommand` and `CloudArgument`. + +```java +package com.example.command; + +import games.negative.engine.command.CloudCommand; +import games.negative.moss.spring.SpringComponent; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import org.incendo.cloud.CommandManager; +import org.incendo.cloud.annotations.Command; +import org.incendo.cloud.annotations.CommandDescription; + +@SpringComponent +public final class ExampleCommand implements PaperCommand { + + @Command("example") + @CommandDescription("Example command") + public void example(CommandSourceStack source) { + + } +} +``` + +## Scheduling and jobs + +Use the static `Scheduler` accessors: + +```java +Scheduler.sync().run(task -> { + // Main-thread/global region work +}); + +Scheduler.async().run(task -> { + // Async work +}); + +Scheduler.entity(player).execute(() -> { + // Entity-thread safe work +}, 1L); +``` + +For recurring background logic, create beans implementing `SyncJob` or `AsyncJob`; `JobScheduler` auto-registers them on enable: + +```java +@SpringComponent +public final class AnnounceJob implements SyncJob { + @Override + public Duration interval() { + return Duration.ofSeconds(30); + } + + @Override + public void tick(ScheduledTask task) { + // Repeating logic + } +} +``` + +## GUI framework + +Use `ChestMenu`, `PaginatedMenu`, or `HopperMenu` with `Button`: + +```java +public final class ExampleMenu extends ChestMenu { + public ExampleMenu(Player player) { + super(player, "Example", 3); + + addButton(13, Button.builder() + .item(viewer -> ItemBuilder.of(Material.EMERALD) + .name("Click me") + .build()) + .action((button, viewer, event) -> viewer.sendMessage(Component.text("Clicked"))) + .build()); + } +} +``` + +Menus are cached per-player and handled by `PlayerInventoryController`. + +## Messages and placeholders + +`Message` and `MiniMessageUtil` support MiniMessage formatting plus PlaceholderAPI integration (when present): + +```java +Message.of("Hello, !") + .send(player, Placeholder.parsed("name", player.getName())); +``` + +`PaperLocalizationPlatform` provides PlaceholderAPI and relational placeholder parsing for Paper audiences. + +## Configuration + +Use `Configuration` with ConfigLib-backed YAML storage: + +```java +public final class ExampleConfig { + public String prefix = "[Example]"; +} + +Configuration config = Configuration.config( + plugin.directory().resolve("config.yml").toFile(), + ExampleConfig.class +); + +String prefix = config.get().prefix; +``` + +## Item building + +`ItemBuilder` simplifies `ItemStack` creation: + +```java +ItemStack stack = ItemBuilder.of(Material.DIAMOND_SWORD) + .name("Starter Sword") + .lore(List.of("Given on join")) + .unbreakable(true) + .glowing(true) + .build(); +``` + +## Utilities in `common` + +- `TimeUtil`: parse/format durations (`1d2h30m`, `H:MM:SS`, etc.) +- `NumberUtil`: decimal formatting, ordinals (`1st`, `2nd`), condensed numbers (`1.2M`) +- `AABB` (paper): simple axis-aligned bounding box representation + +## Build and publish + +From repository root: + +```bash +./gradlew clean build +``` + +Publishing (used by CI for `release` / `snapshot` branches): + +```bash +./gradlew clean publish -PisRelease=true # releases repo +./gradlew clean publish -PisRelease=false # snapshots repo +``` + diff --git a/paper/src/main/java/games/negative/engine/paper/command/PaperCommand.java b/paper/src/main/java/games/negative/engine/paper/command/PaperCommand.java new file mode 100644 index 0000000..c592fd3 --- /dev/null +++ b/paper/src/main/java/games/negative/engine/paper/command/PaperCommand.java @@ -0,0 +1,8 @@ +package games.negative.engine.paper.command; + +import games.negative.engine.command.CloudCommand; +import io.papermc.paper.command.brigadier.CommandSourceStack; + +public interface PaperCommand extends CloudCommand { + +} diff --git a/paper/src/main/java/games/negative/engine/paper/command/PaperCommandRegistry.java b/paper/src/main/java/games/negative/engine/paper/command/PaperCommandRegistry.java index cb4b5e3..432f79c 100644 --- a/paper/src/main/java/games/negative/engine/paper/command/PaperCommandRegistry.java +++ b/paper/src/main/java/games/negative/engine/paper/command/PaperCommandRegistry.java @@ -1,7 +1,6 @@ package games.negative.engine.paper.command; import games.negative.engine.command.CloudArgument; -import games.negative.engine.command.CloudCommand; import games.negative.engine.paper.PaperPlugin; import games.negative.moss.spring.Enableable; import games.negative.moss.spring.SpringComponent; @@ -40,7 +39,7 @@ public void onEnable() { AnnotationParser annotationParser = new AnnotationParser<>(commands, CommandSourceStack.class); - plugin.invokeBeans(CloudCommand.class, command -> { + plugin.invokeBeans(PaperCommand.class, command -> { command.onRegister(commands); annotationParser.parse(command); log.info("Registered command: {}", command.getClass().getSimpleName()); From 03f0b5f62689176d92e51e5dd1b4301d7555980b Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 26 Feb 2026 01:13:52 -0400 Subject: [PATCH 05/12] Add IntList utility to parse integer ranges Introduce IntList with a static parse(List) method that converts strings into a List. Supports single integers and inclusive ranges (e.g., "5-10"); input segments are trimmed before parsing. Useful for parsing config or input lists; invalid numeric input will throw NumberFormatException. --- .../games/negative/engine/util/IntList.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 common/src/main/java/games/negative/engine/util/IntList.java diff --git a/common/src/main/java/games/negative/engine/util/IntList.java b/common/src/main/java/games/negative/engine/util/IntList.java new file mode 100644 index 0000000..068b521 --- /dev/null +++ b/common/src/main/java/games/negative/engine/util/IntList.java @@ -0,0 +1,34 @@ +package games.negative.engine.util; + +import java.util.ArrayList; +import java.util.List; + +public class IntList { + + /** + * Parses a list of strings into a list of integers. + * Strings can represent single integers or ranges (e.g., "5-10"). + * + * @param strings The list of strings to parse. + * @return A list of integers. + */ + public static List parse(List strings) { + List list = new ArrayList<>(); + + for (String string : strings) { + if (string.contains("-")) { + String[] range = string.split("-", 2); + int start = Integer.parseInt(range[0].trim()); + int end = Integer.parseInt(range[1].trim()); + + for (int i = start; i <= end; i++) { + list.add(i); + } + } else { + list.add(Integer.parseInt(string.trim())); + } + } + + return list; + } +} \ No newline at end of file From c4fa4717978d3fb1f248b203bac721170e0e4ba8 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 26 Feb 2026 01:15:53 -0400 Subject: [PATCH 06/12] Add JsonUtil utility for Gson file I/O Introduce JsonUtil, a final utility class (Slf4j) providing Gson-based JSON file operations. Adds methods to load/save objects: loadFromFile, loadTypeFromFile, loadFromDirectory, saveToFile and saveTypeToFile. Uses UTF-8 encoding, Optional/Collection return types, basic file existence checks and error logging, and prevents instantiation. --- .../negative/engine/paper/util/JsonUtil.java | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 paper/src/main/java/games/negative/engine/paper/util/JsonUtil.java diff --git a/paper/src/main/java/games/negative/engine/paper/util/JsonUtil.java b/paper/src/main/java/games/negative/engine/paper/util/JsonUtil.java new file mode 100644 index 0000000..01628c3 --- /dev/null +++ b/paper/src/main/java/games/negative/engine/paper/util/JsonUtil.java @@ -0,0 +1,138 @@ +package games.negative.engine.paper.util; + + +import com.google.gson.Gson; +import lombok.extern.slf4j.Slf4j; + +import java.io.*; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * Utility class for JSON operations using Gson. + */ +@Slf4j +public final class JsonUtil { + + private JsonUtil() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + /** + * Loads a JSON object from a file. + * @param file the file to load from + * @param clazz the class of the object to load + * @param gson the Gson instance to use for deserialization + * @param the type of the object to load + * @return an Optional containing the loaded object, or empty if loading failed + */ + public static Optional loadFromFile(File file, Class clazz, Gson gson) { + if (file == null || !file.exists() || !file.isFile()) { + log.error("File {} does not exist or is not a valid file", file != null ? file.getAbsolutePath() : "null"); + return Optional.empty(); + } + + try (Reader reader = new FileReader(file, StandardCharsets.UTF_8)) { + T object = gson.fromJson(reader, clazz); + return Optional.ofNullable(object); + } catch (IOException e) { + log.error("Failed to load json from file {}", file.getAbsolutePath(), e); + return Optional.empty(); + } + } + + /** + * Loads a JSON object from a file using a Type. + * @param file the file to load from + * @param type the Type of the object to load + * @param gson the Gson instance to use for deserialization + * @param the type of the object to load + * @return an Optional containing the loaded object, or empty if loading failed + */ + public static Optional loadTypeFromFile(File file, Type type, Gson gson) { + if (file == null || !file.exists() || !file.isFile()) { + log.error("File {} does not exist or is not a valid file", file != null ? file.getAbsolutePath() : "null"); + return Optional.empty(); + } + + try (Reader reader = new FileReader(file, StandardCharsets.UTF_8)) { + T object = gson.fromJson(reader, type); + return Optional.ofNullable(object); + } catch (IOException e) { + log.error("Failed to load json from file {}", file.getAbsolutePath(), e); + return Optional.empty(); + } + } + + /** + * Loads all JSON objects from a directory. + * @param directory the directory to load from + * @param clazz the class of the objects to load + * @param gson the Gson instance to use for deserialization + * @param the type of the objects to load + * @return a collection of loaded objects + */ + public static Collection loadFromDirectory(File directory, Class clazz, Gson gson) { + if (directory == null || !directory.exists() || !directory.isDirectory()) { + log.error("Directory {} does not exist or is not a valid directory", directory != null ? directory.getAbsolutePath() : "null"); + return Collections.emptyList(); + } + + List objects = new ArrayList<>(); + + File[] files = directory.listFiles((dir, name) -> name.toLowerCase().endsWith(".json")); + if (files == null) return objects; + + for (File file : files) { + loadFromFile(file, clazz, gson).ifPresent(objects::add); + } + + return objects; + } + + /** + * Saves a JSON object to a file. + * @param file the file to save to + * @param object the object to save + * @param gson the Gson instance to use for serialization + * @param the type of the object to save + */ + public static void saveToFile(File file, T object, Gson gson) { + if (file == null) { + log.error("File is null, cannot save object"); + return; + } + + if (!file.exists()) { + try { + file.createNewFile(); + } catch (IOException e) { + log.error("Failed to save file {}", file.getAbsolutePath(), e); + return; + } + } + + try (Writer writer = new FileWriter(file, StandardCharsets.UTF_8)) { + gson.toJson(object, writer); + } catch (IOException e) { + log.error("Failed to save to file {}", file.getAbsolutePath(), e); + } + } + + /** + * Saves a JSON object to a file using a Type. + * @param file the file to save to + * @param type the Type of the object to save + * @param gson the Gson instance to use for serialization + */ + public static void saveTypeToFile(File file, Type type, Gson gson) { + try (Writer writer = new FileWriter(file, StandardCharsets.UTF_8)) { + gson.toJson(type, writer); + } catch (IOException e) { + log.error("Failed to save to file {}", file.getAbsolutePath(), e); + } + } + +} + From bd985abcfedaca37667fdd9adbd38c4eff1ae935 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 26 Feb 2026 01:18:16 -0400 Subject: [PATCH 07/12] Add Cooldowns component for player cooldowns Introduce a Cooldowns Spring component and Bukkit Listener to manage per-player, per-key cooldowns. Uses a Guava Table backed by concurrent maps to store expiry timestamps (epoch millis), provides addCooldown overloads (millis and Duration) and an isOnCooldown check. Clears a player's cooldowns on PlayerQuitEvent to prevent memory leaks. --- .../engine/paper/cooldown/Cooldowns.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 paper/src/main/java/games/negative/engine/paper/cooldown/Cooldowns.java diff --git a/paper/src/main/java/games/negative/engine/paper/cooldown/Cooldowns.java b/paper/src/main/java/games/negative/engine/paper/cooldown/Cooldowns.java new file mode 100644 index 0000000..aa868ae --- /dev/null +++ b/paper/src/main/java/games/negative/engine/paper/cooldown/Cooldowns.java @@ -0,0 +1,60 @@ +package games.negative.engine.paper.cooldown; + +import com.google.common.collect.Table; +import com.google.common.collect.Tables; +import games.negative.moss.spring.SpringComponent; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@SpringComponent +public class Cooldowns implements Listener { + + private static final Table COOLDOWNS = Tables.newCustomTable( + new ConcurrentHashMap<>(), + ConcurrentHashMap::new + ); + + /** + * Adds a cooldown for the specified UUID and key. + * @param uuid UUID of the entity + * @param key Cooldown key + * @param millis Duration of the cooldown in milliseconds + */ + public static void addCooldown(UUID uuid, String key, long millis) { + long expiryTime = System.currentTimeMillis() + millis; + + COOLDOWNS.put(uuid, key, expiryTime); + } + + /** + * Adds a cooldown for the specified UUID and key. + * @param uuid UUID of the entity + * @param key Cooldown key + * @param duration Duration of the cooldown + */ + public static void addCooldown(UUID uuid, String key, Duration duration) { + addCooldown(uuid, key, duration.toMillis()); + } + + /** + * Checks if the specified UUID and key is on cooldown. + * @param uuid UUID of the entity + * @param key Cooldown key + * @return true if on cooldown, false otherwise + */ + public static boolean isOnCooldown(UUID uuid, String key) { + return COOLDOWNS.contains(uuid, key) && COOLDOWNS.get(uuid, key) > System.currentTimeMillis(); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + COOLDOWNS.row(event.getPlayer().getUniqueId()).clear(); + } + +} + From bfca3c430241eb76e39b21c616fbd964cc75603e Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 26 Feb 2026 01:19:22 -0400 Subject: [PATCH 08/12] Add OptionalBool utility for boolean ops Introduce OptionalBool to wrap boolean values and provide Optional-like, functional-style operations. The class uses singleton instances for true/false, offers isTrue/isFalse checks, conditional executors (ifTrue, ifFalse, ifTrueOrElse) and mapping utilities (mapIfTrue, mapIfFalse, mapIfTrueOrElse) that return Optional or computed values. Also overrides equals, hashCode, and toString. Placed in games.negative.engine.util for use across the codebase. --- .../negative/engine/util/OptionalBool.java | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 common/src/main/java/games/negative/engine/util/OptionalBool.java diff --git a/common/src/main/java/games/negative/engine/util/OptionalBool.java b/common/src/main/java/games/negative/engine/util/OptionalBool.java new file mode 100644 index 0000000..232c2fc --- /dev/null +++ b/common/src/main/java/games/negative/engine/util/OptionalBool.java @@ -0,0 +1,221 @@ +package games.negative.engine.util; + +import java.util.Optional; + +/** + * A utility class that wraps a boolean value and provides functional-style operations + * for conditional execution and value mapping based on the boolean state. + * + *

This class is similar to {@link Optional} but specifically designed for boolean values, + * offering convenient methods for executing actions conditionally and mapping values + * based on the true/false state.

+ * + *

The class uses a singleton pattern for the two possible instances ({@code true} and {@code false}) + * to ensure memory efficiency and reference equality for instances with the same boolean value.

+ * + *

Usage Examples:

+ *
{@code
+ * OptionalBool condition = OptionalBool.of(someCondition);
+ *
+ * // Conditional execution
+ * condition.ifTrue(() -> System.out.println("Condition is true"));
+ * condition.ifTrueOrElse(
+ *     () -> performTrueAction(),
+ *     () -> performFalseAction()
+ * );
+ *
+ * // Value mapping
+ * Optional result = condition.mapIfTrue(() -> "Success");
+ * String value = condition.mapIfTrueOrElse(
+ *     () -> "True value",
+ *     () -> "False value"
+ * );
+ * }
+ */ +public class OptionalBool { + + private static final OptionalBool TRUE = new OptionalBool(true); + private static final OptionalBool FALSE = new OptionalBool(false); + + private final boolean value; + + /** + * Private constructor to enforce singleton pattern. + * + * @param value the boolean value to wrap + */ + private OptionalBool(boolean value) { + this.value = value; + } + + /** + * Creates an {@code OptionalBool} instance for the given boolean value. + * + *

This method returns one of two singleton instances to ensure memory efficiency + * and allow reference equality comparisons.

+ * + * @param value the boolean value to wrap + * @return {@code OptionalBool.TRUE} if value is {@code true}, + * {@code OptionalBool.FALSE} if value is {@code false} + */ + public static OptionalBool of(boolean value) { + return value ? TRUE : FALSE; + } + + /** + * Checks if the wrapped boolean value is {@code true}. + * + * @return {@code true} if the wrapped value is {@code true}, {@code false} otherwise + */ + public boolean isTrue() { + return value; + } + + /** + * Checks if the wrapped boolean value is {@code false}. + * + * @return {@code true} if the wrapped value is {@code false}, {@code false} otherwise + */ + public boolean isFalse() { + return !value; + } + + /** + * Executes the given action if the wrapped boolean value is {@code true}. + * + *

If the value is {@code false}, no action is performed.

+ * + * @param action the action to execute if the value is {@code true} + * @throws NullPointerException if action is null and the value is {@code true} + */ + public void ifTrue(Runnable action) { + if (value) action.run(); + } + + /** + * Executes the given action if the wrapped boolean value is {@code false}. + * + *

If the value is {@code true}, no action is performed.

+ * + * @param action the action to execute if the value is {@code false} + * @throws NullPointerException if action is null and the value is {@code false} + */ + public void ifFalse(Runnable action) { + if (!value) action.run(); + } + + /** + * Executes one of two actions based on the wrapped boolean value. + * + *

If the value is {@code true}, the {@code trueAction} is executed. + * If the value is {@code false}, the {@code falseAction} is executed.

+ * + * @param trueAction the action to execute if the value is {@code true} + * @param falseAction the action to execute if the value is {@code false} + * @throws NullPointerException if the corresponding action is null + */ + public void ifTrueOrElse(Runnable trueAction, Runnable falseAction) { + if (value) { + trueAction.run(); + } else { + falseAction.run(); + } + } + + /** + * Maps the wrapped boolean to a value using the provided callback if the value is {@code true}. + * + *

If the wrapped value is {@code true}, the callback is executed and its result + * is wrapped in an {@link Optional}. If the wrapped value is {@code false}, + * an empty {@code Optional} is returned.

+ * + * @param the type of the value to be returned + * @param callback the callback to execute if the value is {@code true} + * @return an {@code Optional} containing the callback result if value is {@code true}, + * or an empty {@code Optional} if value is {@code false} + * @throws NullPointerException if callback is null and the value is {@code true} + */ + public Optional mapIfTrue(Callback callback) { + if (isFalse()) return Optional.empty(); + + return Optional.of(callback.apply()); + } + + /** + * Maps the wrapped boolean to a value using the provided callback if the value is {@code false}. + * + *

If the wrapped value is {@code false}, the callback is executed and its result + * is wrapped in an {@link Optional}. If the wrapped value is {@code true}, + * an empty {@code Optional} is returned.

+ * + * @param the type of the value to be returned + * @param callback the callback to execute if the value is {@code false} + * @return an {@code Optional} containing the callback result if value is {@code false}, + * or an empty {@code Optional} if value is {@code true} + * @throws NullPointerException if callback is null and the value is {@code false} + */ + public Optional mapIfFalse(Callback callback) { + if (isTrue()) return Optional.empty(); + + return Optional.of(callback.apply()); + } + + /** + * Maps the wrapped boolean to a value using one of two callbacks based on the boolean state. + * + *

If the wrapped value is {@code true}, the {@code trueCallback} is executed and its result returned. + * If the wrapped value is {@code false}, the {@code falseCallback} is executed and its result returned.

+ * + * @param the type of the value to be returned + * @param trueCallback the callback to execute if the value is {@code true} + * @param falseCallback the callback to execute if the value is {@code false} + * @return the result of the appropriate callback + * @throws NullPointerException if the corresponding callback is null + */ + public T mapIfTrueOrElse(Callback trueCallback, Callback falseCallback) { + if (isTrue()) { + return trueCallback.apply(); + } else { + return falseCallback.apply(); + } + } + + /** + * Indicates whether some other object is "equal to" this {@code OptionalBool}. + * + *

Two {@code OptionalBool} instances are considered equal if they wrap the same boolean value.

+ * + * @param obj the reference object with which to compare + * @return {@code true} if this object is the same as the obj argument; {@code false} otherwise + */ + @Override + public boolean equals(Object obj) { + return obj instanceof OptionalBool && ((OptionalBool) obj).value == value; + } + + /** + * Returns a hash code value for this {@code OptionalBool}. + * + *

The hash code is based on the wrapped boolean value.

+ * + * @return a hash code value for this object + */ + @Override + public int hashCode() { + return Boolean.hashCode(value); + } + + /** + * Returns a string representation of this {@code OptionalBool}. + * + *

The string representation consists of the class name followed by + * the wrapped boolean value in square brackets.

+ * + * @return a string representation of this {@code OptionalBool} + */ + @Override + public String toString() { + return "OptionalBool[" + value + "]"; + } +} + From bd608ed884a1a7e149016f397177356f91895ed6 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 26 Feb 2026 01:51:49 -0400 Subject: [PATCH 09/12] Add Reloadable and reload impl to PaperPlugin Introduce a new Reloadable interface with a single reload() method and implement plugin reload handling in PaperPlugin. PaperPlugin now invokes Reloadable beans via invokeBeans, calling their reload methods and logging any failures using SLF4J (@Slf4j). Adds the new common interface file and updates imports in the Paper module. --- .../java/games/negative/engine/state/Reloadable.java | 7 +++++++ .../java/games/negative/engine/paper/PaperPlugin.java | 11 +++++++++++ 2 files changed, 18 insertions(+) create mode 100644 common/src/main/java/games/negative/engine/state/Reloadable.java diff --git a/common/src/main/java/games/negative/engine/state/Reloadable.java b/common/src/main/java/games/negative/engine/state/Reloadable.java new file mode 100644 index 0000000..425e493 --- /dev/null +++ b/common/src/main/java/games/negative/engine/state/Reloadable.java @@ -0,0 +1,7 @@ +package games.negative.engine.state; + +public interface Reloadable { + + void reload(); + +} diff --git a/paper/src/main/java/games/negative/engine/paper/PaperPlugin.java b/paper/src/main/java/games/negative/engine/paper/PaperPlugin.java index 2554abd..2edf485 100644 --- a/paper/src/main/java/games/negative/engine/paper/PaperPlugin.java +++ b/paper/src/main/java/games/negative/engine/paper/PaperPlugin.java @@ -3,7 +3,9 @@ import games.negative.engine.Plugin; import games.negative.engine.message.util.MiniMessageUtil; import games.negative.engine.paper.scheduler.Scheduler; +import games.negative.engine.state.Reloadable; import games.negative.moss.paper.MossPaper; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import java.nio.file.Path; @@ -14,6 +16,7 @@ * An abstract base class for Paper plugins that integrates with the Moss framework. * This class provides common functionality for managing plugin data directories and fetching beans from the Moss context. */ +@Slf4j public abstract class PaperPlugin extends MossPaper implements Plugin { @Override @@ -39,4 +42,12 @@ public void fetchBeans(Class clazz, Consumer consumer) { invokeBeans(clazz, consumer); } + @Override + public void reload() { + invokeBeans( + Reloadable.class, + Reloadable::reload, + (reloadable, e) -> log.error("Failed to reload {}", reloadable.getClass().getSimpleName(), e) + ); + } } From 4d1d21fe57b3ceb2d8ce3ca057754fadcaaca2f5 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 26 Feb 2026 02:13:21 -0400 Subject: [PATCH 10/12] Register Spring Listeners on enable; log commands PaperPlugin: onEnable now invokes Spring beans of type org.bukkit.event.Listener and registers them with the server PluginManager, logging any registration failures. Added the Listener import. PaperCommandRegistry: added an info log "Registering commands" at the start of onEnable to surface command registration in logs. --- .../games/negative/engine/paper/PaperPlugin.java | 12 ++++++++++++ .../engine/paper/command/PaperCommandRegistry.java | 1 + 2 files changed, 13 insertions(+) diff --git a/paper/src/main/java/games/negative/engine/paper/PaperPlugin.java b/paper/src/main/java/games/negative/engine/paper/PaperPlugin.java index 2edf485..775ba17 100644 --- a/paper/src/main/java/games/negative/engine/paper/PaperPlugin.java +++ b/paper/src/main/java/games/negative/engine/paper/PaperPlugin.java @@ -6,6 +6,7 @@ import games.negative.engine.state.Reloadable; import games.negative.moss.paper.MossPaper; import lombok.extern.slf4j.Slf4j; +import org.bukkit.event.Listener; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import java.nio.file.Path; @@ -27,6 +28,17 @@ public void loadInitialComponents(AnnotationConfigApplicationContext context) { MiniMessageUtil.init(); } + @Override + public void onEnable() { + super.onEnable(); + + invokeBeans( + Listener.class, + listener -> getServer().getPluginManager().registerEvents(listener, this), + (listener, e) -> log.error("Failed to register listener: {}", listener.getClass().getSimpleName(), e) + ); + } + @Override public Path directory() { return getDataPath().toAbsolutePath(); diff --git a/paper/src/main/java/games/negative/engine/paper/command/PaperCommandRegistry.java b/paper/src/main/java/games/negative/engine/paper/command/PaperCommandRegistry.java index 432f79c..af8ec1d 100644 --- a/paper/src/main/java/games/negative/engine/paper/command/PaperCommandRegistry.java +++ b/paper/src/main/java/games/negative/engine/paper/command/PaperCommandRegistry.java @@ -22,6 +22,7 @@ public class PaperCommandRegistry implements Enableable { @Override public void onEnable() { + log.info("Registering commands"); PaperCommandManager commands = PaperCommandManager.builder() .executionCoordinator(ExecutionCoordinator.asyncCoordinator()) .buildOnEnable(plugin); From ff332a3626a32a6a081c70b9402fab7361b572f5 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 26 Feb 2026 04:40:09 -0400 Subject: [PATCH 11/12] Bump API version to 1.1.0 Update apiVersion from 1.0.0 to 1.1.0 in common/build.gradle.kts and paper/build.gradle.kts to reflect the API minor release. --- common/build.gradle.kts | 2 +- paper/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 5435a1f..3660329 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -6,7 +6,7 @@ plugins { var id = "plugin-engine-common" var domain = "games.negative.engine" -var apiVersion = "1.0.0" +var apiVersion = "1.1.0" repositories { mavenCentral() diff --git a/paper/build.gradle.kts b/paper/build.gradle.kts index 49a3616..e29b0e9 100644 --- a/paper/build.gradle.kts +++ b/paper/build.gradle.kts @@ -6,7 +6,7 @@ plugins { var id = "plugin-engine-paper" var domain = "games.negative.engine" -var apiVersion = "1.0.0" +var apiVersion = "1.1.0" repositories { mavenCentral() From 388fc4eee3a58120c0ce8bd0289b9b4c51ebac8d Mon Sep 17 00:00:00 2001 From: Eric Date: Sun, 1 Mar 2026 19:07:05 -0400 Subject: [PATCH 12/12] Extract MenuInteractionUtil and add tests Centralize repeated GUI logic by adding MenuInteractionUtil (processClick, openMenu, refreshButton, addButton, checkCancelClick) and update ChestMenu, HopperMenu and PaginatedMenu to delegate to it. Add unit tests for IntList, NumberUtil, OptionalBool and TimeUtil and configure JUnit in common/build.gradle.kts (useJUnitPlatform and junit dependencies). Small cleanups: tweak NumberUtil.condense and a TimeUtil comment, remove an unused import in PaperLocalizationPlatform, adjust JsonUtil imports, add .github/copilot-instructions.md, and ignore /.desloppify in .gitignore. These changes reduce duplication in GUI classes and improve test coverage. --- .github/copilot-instructions.md | 170 ++++++++++++++++++ .gitignore | 1 + common/build.gradle.kts | 11 +- .../negative/engine/util/NumberUtil.java | 19 +- .../games/negative/engine/util/TimeUtil.java | 2 +- .../negative/engine/util/IntListTest.java | 18 ++ .../negative/engine/util/NumberUtilTest.java | 24 +++ .../engine/util/OptionalBoolTest.java | 42 +++++ .../negative/engine/util/TimeUtilTest.java | 26 +++ .../negative/engine/paper/gui/ChestMenu.java | 58 +----- .../negative/engine/paper/gui/HopperMenu.java | 58 +----- .../engine/paper/gui/PaginatedMenu.java | 39 +--- .../paper/gui/util/MenuInteractionUtil.java | 105 +++++++++++ .../platform/PaperLocalizationPlatform.java | 1 - .../negative/engine/paper/util/JsonUtil.java | 13 +- 15 files changed, 435 insertions(+), 152 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 common/src/test/java/games/negative/engine/util/IntListTest.java create mode 100644 common/src/test/java/games/negative/engine/util/NumberUtilTest.java create mode 100644 common/src/test/java/games/negative/engine/util/OptionalBoolTest.java create mode 100644 common/src/test/java/games/negative/engine/util/TimeUtilTest.java create mode 100644 paper/src/main/java/games/negative/engine/paper/gui/util/MenuInteractionUtil.java diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..bdf2dd7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,170 @@ + + +--- +name: desloppify +description: > + Codebase health scanner and technical debt tracker. Use when the user asks + about code quality, technical debt, dead code, large files, god classes, + duplicate functions, code smells, naming issues, import cycles, or coupling + problems. Also use when asked for a health score, what to fix next, or to + create a cleanup plan. Supports 28 languages. +allowed-tools: Bash(desloppify *) +--- + +# Desloppify + +## 1. Your Job + +Improve code quality by maximising the **strict score** honestly. + +**The main thing you do is run `desloppify next`** — it tells you exactly what to fix and how. Fix it, resolve it, run `next` again. Keep going. + +Follow the scan output's **INSTRUCTIONS FOR AGENTS** — don't substitute your own analysis. + +## 2. The Workflow + +Two loops. The **outer loop** rescans periodically to measure progress. +The **inner loop** is where you spend most of your time: fixing issues one by one. + +### Outer loop — scan and check + +```bash +desloppify scan --path . # analyse the codebase +desloppify status # check scores — are we at target? +``` +If not at target, work the inner loop. Rescan periodically — especially after clearing a cluster or batch of related fixes. Issues cascade-resolve and new ones may surface. + +### Inner loop — fix issues + +Repeat until the queue is clear: + +``` +1. desloppify next ← tells you exactly what to fix next +2. Fix the issue in code +3. Resolve it (next shows you the exact command including required attestation) +``` + +Score may temporarily drop after fixes — cascade effects are normal, keep going. +If `next` suggests an auto-fixer, run `desloppify fix --dry-run` to preview, then apply. + +**To be strategic**, use `plan` to shape what `next` gives you: +```bash +desloppify plan # see the full ordered queue +desloppify plan move top # reorder — what unblocks the most? +desloppify plan cluster create # group related issues to batch-fix +desloppify plan focus # scope next to one cluster +desloppify plan defer # push low-value items aside +desloppify plan skip # hide from next +desloppify plan done # mark complete +desloppify plan reopen # reopen +``` + +### Subjective reviews + +The scan will prompt you when a subjective review is needed — just follow its instructions. +If you need to trigger one manually: +```bash +desloppify review --run-batches --runner codex --parallel --scan-after-import +``` + +### Other useful commands + +```bash +desloppify next --count 5 # top 5 priorities +desloppify next --cluster # drill into a cluster +desloppify show # filter by file/detector/ID +desloppify show --status open # all open findings +desloppify plan skip --permanent "" --note "reason" # accept debt (lowers strict score) +desloppify scan --path . --reset-subjective # reset subjective baseline to 0 +``` + +## 3. Reference + +### How scoring works + +Overall score = **40% mechanical** + **60% subjective**. + +- **Mechanical (40%)**: auto-detected issues — duplication, dead code, smells, unused imports, security. Fixed by changing code and rescanning. +- **Subjective (60%)**: design quality review — naming, error handling, abstractions, clarity. Starts at **0%** until reviewed. The scan will prompt you when a review is needed. +- **Strict score** is the north star: wontfix items count as open. The gap between overall and strict is your wontfix debt. +- **Score types**: overall (lenient), strict (wontfix counts), objective (mechanical only), verified (confirmed fixes only). + +### Subjective reviews in detail + +- **Preferred**: `desloppify review --run-batches --runner codex --parallel --scan-after-import` — does everything in one command. +- **Manual path**: `desloppify review --prepare` → review per dimension → `desloppify review --import file.json`. +- Import first, fix after — import creates tracked state entries for correlation. +- Integrity: reviewers score from evidence only. Scores hitting exact targets trigger auto-reset. +- Even moderate scores (60-80) dramatically improve overall health. +- Stale dimensions auto-surface in `next` — just follow the queue. + +### Key concepts + +- **Tiers**: T1 auto-fix → T2 quick manual → T3 judgment call → T4 major refactor. +- **Auto-clusters**: related findings are auto-grouped in `next`. Drill in with `next --cluster `. +- **Zones**: production/script (scored), test/config/generated/vendor (not scored). Fix with `zone set`. +- **Wontfix cost**: widens the lenient↔strict gap. Challenge past decisions when the gap grows. +- Score can temporarily drop after fixes (cascade effects are normal). + +## 4. Escalate Tool Issues Upstream + +When desloppify itself appears wrong or inconsistent: + +1. Capture a minimal repro (`command`, `path`, `expected`, `actual`). +2. Open a GitHub issue in `peteromallet/desloppify`. +3. If you can fix it safely, open a PR linked to that issue. +4. If unsure whether it is tool bug vs user workflow, issue first, PR second. + +## Prerequisite + +`command -v desloppify >/dev/null 2>&1 && echo "desloppify: installed" || echo "NOT INSTALLED — run: pip install --upgrade git+https://github.com/peteromallet/desloppify.git"` + + + +## VS Code Copilot Overlay + +VS Code Copilot supports native subagents via `.github/agents/` definitions. +Use them for context-isolated subjective reviews. + +### Subjective review + +1. **Preferred**: `desloppify review --run-batches --runner codex --parallel --scan-after-import`. +2. **Copilot/cloud path**: `desloppify review --external-start --external-runner claude` → use generated prompt/template → run printed `--external-submit` command. +3. **Manual path**: define a reviewer agent, split dimensions, merge, import. + +For the manual path, define a reviewer in `.github/agents/desloppify-reviewer.md`: + +```yaml +--- +name: desloppify-reviewer +tools: ['read', 'search'] +--- +You are a code quality reviewer. You will be given a codebase path, a set of +dimensions to score, and what each dimension means. Read the code, score each +dimension 0-100 from evidence only, and return JSON in the required format. +Do not anchor to target thresholds. When evidence is mixed, score lower and +explain uncertainty. +``` + +And an orchestrator in `.github/agents/desloppify-review-orchestrator.md`: + +```yaml +--- +name: desloppify-review-orchestrator +tools: ['agent', 'read', 'search'] +agents: ['desloppify-reviewer'] +--- +``` + +Split dimensions across `desloppify-reviewer` calls (Copilot runs them concurrently), merge assessments (average overlaps) and findings, then import. + +### Review integrity + +1. Do not use prior chat context, score history, or target-threshold anchoring while scoring. +2. Score from evidence only; when mixed, score lower and explain uncertainty. +3. Return JSON matching the format in the base skill doc. For `--external-submit`, include `session` from the generated template. +4. `findings` MUST match `query.system_prompt` exactly. Use `"findings": []` when no defects found. +5. Import is fail-closed: invalid findings abort unless `--allow-partial` is passed. + + + diff --git a/.gitignore b/.gitignore index bf3e1b2..10c5e64 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ bin/ ### Mac OS ### .DS_Store /.idea/ +/.desloppify diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 3660329..7a124b1 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -39,6 +39,11 @@ dependencies { // Lombok compileOnly("org.projectlombok:lombok:1.18.32") annotationProcessor("org.projectlombok:lombok:1.18.32") + + // Testing + testImplementation(platform("org.junit:junit-bom:5.12.2")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } java { @@ -53,6 +58,10 @@ tasks.shadowJar { archiveClassifier.set("") } +tasks.test { + useJUnitPlatform() +} + publishing { publications { create("mavenJava") { @@ -85,4 +94,4 @@ publishing { } } } -} \ No newline at end of file +} diff --git a/common/src/main/java/games/negative/engine/util/NumberUtil.java b/common/src/main/java/games/negative/engine/util/NumberUtil.java index 0234e09..d6999b6 100644 --- a/common/src/main/java/games/negative/engine/util/NumberUtil.java +++ b/common/src/main/java/games/negative/engine/util/NumberUtil.java @@ -391,18 +391,19 @@ public static String condense(BigDecimal number) { */ public static String condense(BigDecimal number, final char[] set) { BigDecimal thousand = BigDecimal.valueOf(1000); + String condensed; if (number.compareTo(thousand) < 0) { - return number.stripTrailingZeros().toPlainString(); // Return the number itself if less than 1000. - } - - int exp = (int) (Math.floor(Math.log10(number.doubleValue()) / 3)); - - String suffixes = (set == null) ? SUFFIXES : new String(set); - char suffix = suffixes.charAt(Math.min(exp - 1, suffixes.length() - 1)); + condensed = number.stripTrailingZeros().toPlainString(); // Return the number itself if less than 1000. + } else { + int exp = (int) (Math.floor(Math.log10(number.doubleValue()) / 3)); - BigDecimal result = number.divide(thousand.pow(exp), 1, RoundingMode.HALF_UP); + String suffixes = (set == null) ? SUFFIXES : new String(set); + char suffix = suffixes.charAt(Math.min(exp - 1, suffixes.length() - 1)); - return String.format("%.1f%c", result, suffix); + BigDecimal result = number.divide(thousand.pow(exp), 1, RoundingMode.HALF_UP); + condensed = String.format("%.1f%c", result, suffix); + } + return condensed; } /** diff --git a/common/src/main/java/games/negative/engine/util/TimeUtil.java b/common/src/main/java/games/negative/engine/util/TimeUtil.java index 6ff1ffb..c8db7c7 100644 --- a/common/src/main/java/games/negative/engine/util/TimeUtil.java +++ b/common/src/main/java/games/negative/engine/util/TimeUtil.java @@ -256,7 +256,7 @@ private static Duration parseUnitFormat(String input) { if (Character.isDigit(c)) { numberBuffer.append(c); } else if (Character.isWhitespace(c)) { - continue; // Allow whitespace + // Allow whitespace. } else { if (numberBuffer.isEmpty()) { throw new IllegalArgumentException( diff --git a/common/src/test/java/games/negative/engine/util/IntListTest.java b/common/src/test/java/games/negative/engine/util/IntListTest.java new file mode 100644 index 0000000..827762b --- /dev/null +++ b/common/src/test/java/games/negative/engine/util/IntListTest.java @@ -0,0 +1,18 @@ +package games.negative.engine.util; + +import games.negative.engine.util.IntList; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class IntListTest { + + @Test + void parseSupportsSinglesAndRanges() { + List values = IntList.parse(List.of("1", "3-5", " 7 ")); + + assertEquals(List.of(1, 3, 4, 5, 7), values); + } +} diff --git a/common/src/test/java/games/negative/engine/util/NumberUtilTest.java b/common/src/test/java/games/negative/engine/util/NumberUtilTest.java new file mode 100644 index 0000000..9bfc449 --- /dev/null +++ b/common/src/test/java/games/negative/engine/util/NumberUtilTest.java @@ -0,0 +1,24 @@ +package games.negative.engine.util; + +import games.negative.engine.util.NumberUtil; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NumberUtilTest { + + @Test + void fancyAddsExpectedSuffixes() { + assertEquals("1st", NumberUtil.fancy(1)); + assertEquals("12th", NumberUtil.fancy(12)); + assertEquals("23rd", NumberUtil.fancy(23)); + } + + @Test + void condenseFormatsValues() { + assertEquals("1.5k", NumberUtil.condense(1500)); + assertEquals("999", NumberUtil.condense(new BigDecimal("999"))); + } +} diff --git a/common/src/test/java/games/negative/engine/util/OptionalBoolTest.java b/common/src/test/java/games/negative/engine/util/OptionalBoolTest.java new file mode 100644 index 0000000..f6f5585 --- /dev/null +++ b/common/src/test/java/games/negative/engine/util/OptionalBoolTest.java @@ -0,0 +1,42 @@ +package games.negative.engine.util; + +import games.negative.engine.util.OptionalBool; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OptionalBoolTest { + + @Test + void ofReturnsSingletonInstances() { + assertSame(OptionalBool.of(true), OptionalBool.of(true)); + assertSame(OptionalBool.of(false), OptionalBool.of(false)); + } + + @Test + void mapIfTrueAndFalseBehaveAsExpected() { + Optional whenTrue = OptionalBool.of(true).mapIfTrue(() -> "value"); + Optional whenFalse = OptionalBool.of(false).mapIfTrue(() -> "value"); + + assertEquals(Optional.of("value"), whenTrue); + assertTrue(whenFalse.isEmpty()); + } + + @Test + void ifTrueOrElseRunsCorrectBranch() { + AtomicInteger marker = new AtomicInteger(0); + OptionalBool.of(false).ifTrueOrElse( + () -> marker.set(1), + () -> marker.set(2) + ); + + assertFalse(OptionalBool.of(false).isTrue()); + assertEquals(2, marker.get()); + } +} diff --git a/common/src/test/java/games/negative/engine/util/TimeUtilTest.java b/common/src/test/java/games/negative/engine/util/TimeUtilTest.java new file mode 100644 index 0000000..f0a6bf1 --- /dev/null +++ b/common/src/test/java/games/negative/engine/util/TimeUtilTest.java @@ -0,0 +1,26 @@ +package games.negative.engine.util; + +import games.negative.engine.util.TimeUtil; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TimeUtilTest { + + @Test + void formatAndParseRoundTripUnitFormat() { + Duration duration = TimeUtil.parse("1h30m"); + assertEquals(Duration.ofMinutes(90), duration); + assertEquals("1h 30m", TimeUtil.format(duration, true)); + } + + @Test + void formatAndParseRoundTripColonFormat() { + Duration duration = Duration.ofHours(1).plusMinutes(5).plusSeconds(2); + String formatted = TimeUtil.formatColonSeparated(duration); + assertEquals("1:05:02", formatted); + assertEquals(duration, TimeUtil.parseColonSeparated(formatted)); + } +} diff --git a/paper/src/main/java/games/negative/engine/paper/gui/ChestMenu.java b/paper/src/main/java/games/negative/engine/paper/gui/ChestMenu.java index 1c85cdc..99b3dae 100644 --- a/paper/src/main/java/games/negative/engine/paper/gui/ChestMenu.java +++ b/paper/src/main/java/games/negative/engine/paper/gui/ChestMenu.java @@ -3,11 +3,10 @@ import com.google.common.base.Preconditions; import games.negative.engine.message.util.MiniMessageUtil; import games.negative.engine.paper.gui.button.Button; +import games.negative.engine.paper.gui.util.MenuInteractionUtil; import games.negative.engine.paper.gui.util.SafeUtil; -import games.negative.engine.paper.scheduler.Scheduler; import net.kyori.adventure.text.Component; import org.bukkit.entity.Player; -import org.bukkit.event.Event; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.inventory.InventoryOpenEvent; @@ -109,21 +108,7 @@ public void onClose(Player player, InventoryCloseEvent event) { @Override public void onClick(Player player, InventoryClickEvent event) { - if (cancelClicks) { - event.setCancelled(true); - event.setResult(Event.Result.DENY); - } - - ItemStack current = event.getCurrentItem(); - if (current == null) return; - - String id = current.getPersistentDataContainer().get(Button.KEY, PersistentDataType.STRING); - if (id == null) return; - - Button button = buttonById.get(UUID.fromString(id)); - if (button == null) return; - - button.processClickAction(player, event); + MenuInteractionUtil.processClick(cancelClicks, buttonById, player, event); } /** @@ -131,13 +116,7 @@ public void onClick(Player player, InventoryClickEvent event) { */ @Override public void open() { - refresh(); - - UserInterface.invalidateFromCache(player.getUniqueId()); - - CACHE.put(player.getUniqueId(), this); - - Scheduler.entity(player).execute(() -> inventory.open(), 1); + MenuInteractionUtil.openMenu(player, this, this::refresh, () -> inventory); } /** @@ -189,18 +168,7 @@ public Collection> getRefreshingButtons() { * @param button The button to refresh */ public void refreshButton(int slot, Button button) { - if (inventory == null) return; - - Player player = (Player) inventory.getPlayer(); - - ItemStack stack = button.item(player); - if (stack == null || stack.getType().isAir()) return; - - stack.editPersistentDataContainer( - data -> data.set(Button.KEY, PersistentDataType.STRING, button.uuid().toString()) - ); - - SafeUtil.setInventoryItem(inventory, slot, stack); + MenuInteractionUtil.refreshButton(inventory, slot, button); } /** @@ -214,12 +182,7 @@ public void addButton(int slot, Button button) { "Slot must be between 0 and " + (rows * 9 - 1) ); - buttons.put(slot, button); - buttonById.put(button.uuid(), button); - - if (button.refreshIntervalTicks() <= 0L) return; - - refreshingButtons.put(slot, button); + MenuInteractionUtil.addButton(slot, button, buttons, buttonById, refreshingButtons); } /** @@ -320,16 +283,7 @@ public boolean cancelClicks() { * @return true if the click should be cancelled */ public boolean checkCancelClick(int slot) { - ItemStack item = inventory.getItem(slot); - if (item == null || item.getType().isAir()) return false; - - String id = item.getPersistentDataContainer().get(Button.KEY, PersistentDataType.STRING); - if (id == null) return false; - - Button button = buttonById.get(UUID.fromString(id)); - if (button == null) return false; - - return button.cancelClick(); + return MenuInteractionUtil.checkCancelClick(inventory, buttonById, slot); } } diff --git a/paper/src/main/java/games/negative/engine/paper/gui/HopperMenu.java b/paper/src/main/java/games/negative/engine/paper/gui/HopperMenu.java index 6e20160..5985480 100644 --- a/paper/src/main/java/games/negative/engine/paper/gui/HopperMenu.java +++ b/paper/src/main/java/games/negative/engine/paper/gui/HopperMenu.java @@ -2,11 +2,10 @@ import games.negative.engine.message.util.MiniMessageUtil; import games.negative.engine.paper.gui.button.Button; +import games.negative.engine.paper.gui.util.MenuInteractionUtil; import games.negative.engine.paper.gui.util.SafeUtil; -import games.negative.engine.paper.scheduler.Scheduler; import net.kyori.adventure.text.Component; import org.bukkit.entity.Player; -import org.bukkit.event.Event; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.inventory.InventoryOpenEvent; @@ -93,21 +92,7 @@ public void onClose(Player player, InventoryCloseEvent event) { @Override public void onClick(Player player, InventoryClickEvent event) { - if (cancelClicks) { - event.setCancelled(true); - event.setResult(Event.Result.DENY); - } - - ItemStack current = event.getCurrentItem(); - if (current == null) return; - - String id = current.getPersistentDataContainer().get(Button.KEY, PersistentDataType.STRING); - if (id == null) return; - - Button button = buttonById.get(UUID.fromString(id)); - if (button == null) return; - - button.processClickAction(player, event); + MenuInteractionUtil.processClick(cancelClicks, buttonById, player, event); } /** @@ -115,13 +100,7 @@ public void onClick(Player player, InventoryClickEvent event) { */ @Override public void open() { - refresh(); - - UserInterface.invalidateFromCache(player.getUniqueId()); - - CACHE.put(player.getUniqueId(), this); - - Scheduler.entity(player).execute(() -> inventory.open(), 1); + MenuInteractionUtil.openMenu(player, this, this::refresh, () -> inventory); } /** @@ -173,18 +152,7 @@ public Collection> getRefreshingButtons() { * @param button The button to refresh */ public void refreshButton(int slot, Button button) { - if (inventory == null) return; - - Player player = (Player) inventory.getPlayer(); - - ItemStack stack = button.item(player); - if (stack == null || stack.getType().isAir()) return; - - stack.editPersistentDataContainer( - data -> data.set(Button.KEY, PersistentDataType.STRING, button.uuid().toString()) - ); - - SafeUtil.setInventoryItem(inventory, slot, stack); + MenuInteractionUtil.refreshButton(inventory, slot, button); } /** @@ -193,12 +161,7 @@ public void refreshButton(int slot, Button button) { * @param button The button to add */ public void addButton(int slot, Button button) { - buttons.put(slot, button); - buttonById.put(button.uuid(), button); - - if (button.refreshIntervalTicks() <= 0L) return; - - refreshingButtons.put(slot, button); + MenuInteractionUtil.addButton(slot, button, buttons, buttonById, refreshingButtons); } /** @@ -299,16 +262,7 @@ public boolean cancelClicks() { * @return true if the click should be cancelled */ public boolean checkCancelClick(int slot) { - ItemStack item = inventory.getItem(slot); - if (item == null || item.getType().isAir()) return false; - - String id = item.getPersistentDataContainer().get(Button.KEY, PersistentDataType.STRING); - if (id == null) return false; - - Button button = buttonById.get(UUID.fromString(id)); - if (button == null) return false; - - return button.cancelClick(); + return MenuInteractionUtil.checkCancelClick(inventory, buttonById, slot); } } diff --git a/paper/src/main/java/games/negative/engine/paper/gui/PaginatedMenu.java b/paper/src/main/java/games/negative/engine/paper/gui/PaginatedMenu.java index 494c5f5..a506fb0 100644 --- a/paper/src/main/java/games/negative/engine/paper/gui/PaginatedMenu.java +++ b/paper/src/main/java/games/negative/engine/paper/gui/PaginatedMenu.java @@ -3,8 +3,8 @@ import com.google.common.base.Preconditions; import games.negative.engine.message.util.MiniMessageUtil; import games.negative.engine.paper.gui.button.Button; +import games.negative.engine.paper.gui.util.MenuInteractionUtil; import games.negative.engine.paper.gui.util.SafeUtil; -import games.negative.engine.paper.scheduler.Scheduler; import lombok.extern.slf4j.Slf4j; import net.kyori.adventure.text.Component; import org.bukkit.entity.Player; @@ -200,16 +200,7 @@ public void onClose(Player player, InventoryCloseEvent event) { */ @Override public void onClick(Player player, InventoryClickEvent event) { - ItemStack current = event.getCurrentItem(); - if (current == null) return; - - String id = current.getPersistentDataContainer().get(Button.KEY, PersistentDataType.STRING); - if (id == null) return; - - Button button = buttonById.get(UUID.fromString(id)); - if (button == null) return; - - button.processClickAction(player, event); + MenuInteractionUtil.processClick(false, buttonById, player, event); } /** @@ -219,13 +210,7 @@ public void onClick(Player player, InventoryClickEvent event) { */ @Override public void open() { - refresh(); - - UserInterface.invalidateFromCache(player.getUniqueId()); - - CACHE.put(player.getUniqueId(), this); - - Scheduler.entity(player).execute(() -> inventory.open(), 1); + MenuInteractionUtil.openMenu(player, this, this::refresh, () -> inventory); } /** @@ -339,12 +324,7 @@ public void addButton(int slot, Button button) { "Slot must be between 0 and " + (rows * 9 - 1) ); - buttons.put(slot, button); - buttonById.put(button.uuid(), button); - - if (button.refreshIntervalTicks() <= 0L) return; - - refreshingButtons.put(slot, button); + MenuInteractionUtil.addButton(slot, button, buttons, buttonById, refreshingButtons); } /** @@ -502,16 +482,7 @@ public boolean cancelClicks() { * @return true if the click should be cancelled */ public boolean checkCancelClick(int slot) { - ItemStack item = inventory.getItem(slot); - if (item == null || item.getType().isAir()) return false; - - String id = item.getPersistentDataContainer().get(Button.KEY, PersistentDataType.STRING); - if (id == null) return false; - - Button button = buttonById.get(UUID.fromString(id)); - if (button == null) return false; - - return button.cancelClick(); + return MenuInteractionUtil.checkCancelClick(inventory, buttonById, slot); } /** diff --git a/paper/src/main/java/games/negative/engine/paper/gui/util/MenuInteractionUtil.java b/paper/src/main/java/games/negative/engine/paper/gui/util/MenuInteractionUtil.java new file mode 100644 index 0000000..f0a50d7 --- /dev/null +++ b/paper/src/main/java/games/negative/engine/paper/gui/util/MenuInteractionUtil.java @@ -0,0 +1,105 @@ +package games.negative.engine.paper.gui.util; + +import games.negative.engine.paper.gui.UserInterface; +import games.negative.engine.paper.gui.button.Button; +import games.negative.engine.paper.scheduler.Scheduler; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; + +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; + +public final class MenuInteractionUtil { + + private MenuInteractionUtil() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static void processClick( + boolean cancelClicks, + Map buttonById, + Player player, + InventoryClickEvent event + ) { + if (cancelClicks) { + event.setCancelled(true); + event.setResult(Event.Result.DENY); + } + + ItemStack current = event.getCurrentItem(); + if (current == null) return; + + String id = current.getPersistentDataContainer().get(Button.KEY, PersistentDataType.STRING); + if (id == null) return; + + Button button = buttonById.get(UUID.fromString(id)); + if (button == null) return; + + button.processClickAction(player, event); + } + + public static void openMenu( + Player player, + UserInterface ui, + Runnable refreshAction, + Supplier inventorySupplier + ) { + refreshAction.run(); + + UserInterface.invalidateFromCache(player.getUniqueId()); + UserInterface.CACHE.put(player.getUniqueId(), ui); + + Scheduler.entity(player).execute(() -> { + InventoryView view = inventorySupplier.get(); + if (view != null) view.open(); + }, 1); + } + + public static void refreshButton(InventoryView inventory, int slot, Button button) { + if (inventory == null) return; + + Player player = (Player) inventory.getPlayer(); + ItemStack stack = button.item(player); + if (stack == null || stack.getType().isAir()) return; + + stack.editPersistentDataContainer( + data -> data.set(Button.KEY, PersistentDataType.STRING, button.uuid().toString()) + ); + + SafeUtil.setInventoryItem(inventory, slot, stack); + } + + public static void addButton( + int slot, + Button button, + Map buttons, + Map buttonById, + Map refreshingButtons + ) { + buttons.put(slot, button); + buttonById.put(button.uuid(), button); + + if (button.refreshIntervalTicks() <= 0L) return; + refreshingButtons.put(slot, button); + } + + public static boolean checkCancelClick(InventoryView inventory, Map buttonById, int slot) { + if (inventory == null) return false; + + ItemStack item = inventory.getItem(slot); + if (item == null || item.getType().isAir()) return false; + + String id = item.getPersistentDataContainer().get(Button.KEY, PersistentDataType.STRING); + if (id == null) return false; + + Button button = buttonById.get(UUID.fromString(id)); + if (button == null) return false; + + return button.cancelClick(); + } +} diff --git a/paper/src/main/java/games/negative/engine/paper/platform/PaperLocalizationPlatform.java b/paper/src/main/java/games/negative/engine/paper/platform/PaperLocalizationPlatform.java index 331081b..7112e67 100644 --- a/paper/src/main/java/games/negative/engine/paper/platform/PaperLocalizationPlatform.java +++ b/paper/src/main/java/games/negative/engine/paper/platform/PaperLocalizationPlatform.java @@ -1,7 +1,6 @@ package games.negative.engine.paper.platform; import games.negative.engine.message.LocalizationPlatform; -import games.negative.engine.message.util.PlaceholderAPIUtil; import games.negative.moss.spring.SpringComponent; import me.clip.placeholderapi.PlaceholderAPI; import net.kyori.adventure.audience.Audience; diff --git a/paper/src/main/java/games/negative/engine/paper/util/JsonUtil.java b/paper/src/main/java/games/negative/engine/paper/util/JsonUtil.java index 01628c3..924714a 100644 --- a/paper/src/main/java/games/negative/engine/paper/util/JsonUtil.java +++ b/paper/src/main/java/games/negative/engine/paper/util/JsonUtil.java @@ -4,10 +4,19 @@ import com.google.gson.Gson; import lombok.extern.slf4j.Slf4j; -import java.io.*; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; /** * Utility class for JSON operations using Gson.