From 55792ee067bd5fb136ac5578642e89305663b47b Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 25 Feb 2026 13:23:41 -0400 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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()