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/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/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/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 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 + "]"; + } +} + 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() 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..775ba17 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,10 @@ 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.bukkit.event.Listener; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import java.nio.file.Path; @@ -14,6 +17,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 @@ -24,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(); @@ -39,4 +54,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) + ); + } } 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..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 @@ -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; @@ -23,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); @@ -40,7 +40,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()); 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(); + } + +} + 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); + } + } + +} +