diff --git a/playtime-api/src/main/java/com/github/imdmk/playtime/PlayTime.java b/playtime-api/src/main/java/com/github/imdmk/playtime/PlayTime.java new file mode 100644 index 0000000..84b1e69 --- /dev/null +++ b/playtime-api/src/main/java/com/github/imdmk/playtime/PlayTime.java @@ -0,0 +1,64 @@ +package com.github.imdmk.playtime; + +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +public record PlayTime(long millis) implements Comparable { + + private static final long MILLIS_PER_TICK = 50L; + + public static final PlayTime ZERO = new PlayTime(0L); + + public PlayTime { + if (millis < 0L) { + throw new IllegalArgumentException("PlayTime millis cannot be negative"); + } + } + + public static PlayTime of(@NotNull Duration duration) { + return new PlayTime(duration.toMillis()); + } + + public static PlayTime ofMillis(long millis) { + return new PlayTime(millis); + } + + public static PlayTime ofTicks(long ticks) { + return new PlayTime(Math.multiplyExact(ticks, MILLIS_PER_TICK)); + } + + public long toMillis() { + return millis; + } + + public Duration toDuration() { + return Duration.ofMillis(millis); + } + + public long toSeconds() { + return TimeUnit.MILLISECONDS.toSeconds(millis); + } + + public int toTicks() { + return Math.toIntExact(millis / MILLIS_PER_TICK); + } + + public boolean isZero() { + return millis == 0; + } + + public PlayTime plus(@NotNull PlayTime other) { + return new PlayTime(Math.addExact(millis, other.millis)); + } + + public PlayTime minus(@NotNull PlayTime other) { + return new PlayTime(Math.subtractExact(millis, other.millis)); + } + + @Override + public int compareTo(PlayTime o) { + return Long.compare(millis, o.millis); + } +} diff --git a/playtime-api/src/main/java/com/github/imdmk/playtime/PlayTimeApi.java b/playtime-api/src/main/java/com/github/imdmk/playtime/PlayTimeApi.java index 7c239fd..08898e0 100644 --- a/playtime-api/src/main/java/com/github/imdmk/playtime/PlayTimeApi.java +++ b/playtime-api/src/main/java/com/github/imdmk/playtime/PlayTimeApi.java @@ -1,59 +1,17 @@ package com.github.imdmk.playtime; -import com.github.imdmk.playtime.user.UserService; import org.jetbrains.annotations.NotNull; -/** - * Central API contract for interacting with the PlayTime plugin’s core services. - * - *

This interface provides unified access to the main subsystems of the plugin:

- * - * - * - *

External plugins can use this interface to integrate with PlayTime features - * without depending on internal implementation details. The implementation is provided - * automatically by the PlayTime plugin during runtime initialization.

- * - *

Usage Example:

- * - *
{@code
- * PlayTimeApi api = PlayTimeApiProvider.get();
- *
- * UserService userService = api.userService();
- * PlaytimeService playtimeService = api.playtimeService();
- *
- * UUID uuid = player.getUniqueId();
- * UserTime time = playtimeService.getTime(uuid);
- * }
- * - * @see PlaytimeService - * @see com.github.imdmk.playtime.user.UserService - * @see com.github.imdmk.playtime.user.UserTime - */ +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + public interface PlayTimeApi { - /** - * Returns the {@link UserService}, which provides access to user-management operations - * such as creating, saving, and retrieving user data including playtime, - * ranks, and metadata. - * - * @return non-null {@link UserService} instance - */ - @NotNull UserService userService(); + CompletableFuture getTime(@NotNull UUID uuid); + + CompletableFuture setTime(@NotNull UUID uuid, @NotNull PlayTime time); + CompletableFuture addTime(@NotNull UUID uuid, @NotNull PlayTime delta); + + CompletableFuture resetTime(@NotNull UUID uuid); - /** - * Returns the {@link PlaytimeService}, which provides high-level operations for - * retrieving and modifying player playtime data. - * - *

This service acts as the bridge between the plugin’s internal user model - * and the underlying storage or platform-specific systems.

- * - * @return non-null {@link PlaytimeService} instance - */ - @NotNull PlaytimeService playtimeService(); } diff --git a/playtime-api/src/main/java/com/github/imdmk/playtime/PlayTimeApiProvider.java b/playtime-api/src/main/java/com/github/imdmk/playtime/PlayTimeApiProvider.java index 0a1ff3c..66478e8 100644 --- a/playtime-api/src/main/java/com/github/imdmk/playtime/PlayTimeApiProvider.java +++ b/playtime-api/src/main/java/com/github/imdmk/playtime/PlayTimeApiProvider.java @@ -2,11 +2,6 @@ import org.jetbrains.annotations.NotNull; -/** - * Static access point for the {@link PlayTimeApi}. - *

- * Thread-safe: publication via synchronized register/unregister and a volatile reference. - */ public final class PlayTimeApiProvider { private static volatile PlayTimeApi API; // visibility across threads @@ -15,35 +10,19 @@ private PlayTimeApiProvider() { throw new UnsupportedOperationException("This class cannot be instantiated."); } - /** - * Returns the registered {@link PlayTimeApi}. - * - * @return the registered API - * @throws IllegalStateException if the API is not registered - */ - public static @NotNull PlayTimeApi get() { - PlayTimeApi api = API; + @NotNull + public static PlayTimeApi get() { + final PlayTimeApi api = API; if (api == null) { throw new IllegalStateException("PlayTimeAPI is not registered."); } return api; } - /** - * Checks if the API is registered - * - * @return {@code true} if the API is registered. - */ public static boolean isRegistered() { return API != null; } - /** - * Registers the {@link PlayTimeApi} instance. - * - * @param api the API instance to register - * @throws IllegalStateException if already registered - */ static synchronized void register(@NotNull PlayTimeApi api) { if (API != null) { throw new IllegalStateException("PlayTimeAPI is already registered."); @@ -51,20 +30,6 @@ static synchronized void register(@NotNull PlayTimeApi api) { API = api; } - /** - * Forces registration of the {@link PlayTimeApi} instance. - *

- * Intended for tests/bootstrap only; overwrites any existing instance. - */ - static synchronized void forceRegister(@NotNull PlayTimeApi api) { - API = api; - } - - /** - * Unregisters the {@link PlayTimeApi}. - * - * @throws IllegalStateException if no API was registered - */ static synchronized void unregister() { if (API == null) { throw new IllegalStateException("PlayTimeAPI is not registered."); diff --git a/playtime-api/src/main/java/com/github/imdmk/playtime/PlaytimeService.java b/playtime-api/src/main/java/com/github/imdmk/playtime/PlaytimeService.java deleted file mode 100644 index cb3895d..0000000 --- a/playtime-api/src/main/java/com/github/imdmk/playtime/PlaytimeService.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.github.imdmk.playtime; - -import com.github.imdmk.playtime.user.UserTime; -import org.jetbrains.annotations.NotNull; - -import java.util.UUID; - -/** - * A high-level abstraction for accessing and modifying player playtime data. - *

- * Implementations of this interface are responsible for bridging between - * the plugin domain model ({@link UserTime}) and the underlying platform’s - * data source (e.g., Bukkit statistics API, database, etc.). - *

- * Playtime is typically expressed in Minecraft ticks (20 ticks = 1 second), - * but the {@link UserTime} abstraction handles conversions to and from human-readable units. - * - * @see com.github.imdmk.playtime.user.UserTime - */ -public interface PlaytimeService { - - /** - * Retrieves the total accumulated playtime for the specified player. - * - * @param uuid - * the unique identifier of the player whose playtime should be fetched; - * must not be {@code null} - * @return - * a non-null {@link UserTime} representing the player’s total playtime. - * If no playtime is recorded or the player has never joined, returns {@link UserTime#ZERO}. - * @throws NullPointerException - * if {@code uuid} is {@code null}. - */ - @NotNull UserTime getTime(@NotNull UUID uuid); - - /** - * Sets the total playtime for the specified player to the given value. - * - * @param uuid - * the unique identifier of the player whose playtime should be updated; - * must not be {@code null} - * @param time - * the new total playtime value to assign; must not be {@code null} - * @throws NullPointerException - * if {@code uuid} or {@code time} is {@code null} - */ - void setTime(@NotNull UUID uuid, @NotNull UserTime time); - - /** - * Resets the total recorded playtime of the specified player to zero. - * - * @param uuid - * the unique identifier of the player whose playtime should be reset; - * must not be {@code null} - * @throws NullPointerException - * if {@code uuid} is {@code null} - */ - void resetTime(@NotNull UUID uuid); -} diff --git a/playtime-api/src/main/java/com/github/imdmk/playtime/user/User.java b/playtime-api/src/main/java/com/github/imdmk/playtime/user/User.java deleted file mode 100644 index ebc559a..0000000 --- a/playtime-api/src/main/java/com/github/imdmk/playtime/user/User.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.github.imdmk.playtime.user; - -import org.jetbrains.annotations.NotNull; - -import java.util.Objects; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Represents an immutable-identity player aggregate containing all tracked - * playtime-related metadata. - * - *

This class is the main domain model for player statistics and provides: - *

    - *
  • stable identity via {@link UUID},
  • - *
  • thread-safe counters using {@link AtomicLong} and {@link AtomicInteger},
  • - *
  • mutable fields for name, join tracking, and playtime accumulation.
  • - *
- * - * All numerical fields are stored in atomic structures to allow safe concurrent - * updates from asynchronous tasks (e.g., an async database writes). The name field - * is {@code volatile}, ensuring safe publication across threads. - *

- * Two {@code User} instances are considered equal if and only if their UUIDs match. - */ -public final class User { - - /** Permanently immutable player UUID. */ - private final UUID uuid; - - /** Last known player name. Volatile for safe cross-thread publication. */ - private volatile String name; - - /** Total accumulated playtime in milliseconds. */ - private final AtomicLong playtimeMillis; - - /** - * Creates a fully initialized {@code User} instance. - * - * @param uuid unique player identifier (never null) - * @param name last known player name (never null or blank) - * @param playtime initial playtime value (never null) - */ - public User(@NotNull UUID uuid, @NotNull String name, @NotNull UserTime playtime) { - Objects.requireNonNull(playtime, "playtime cannot be null"); - - this.uuid = Objects.requireNonNull(uuid, "uuid cannot be null"); - this.name = Objects.requireNonNull(name, "name cannot be null"); - this.playtimeMillis = new AtomicLong(playtime.millis()); - } - - /** - * Convenience constructor for a new player with zero playtime. - * - * @param uuid unique player identifier - * @param name last known player name - */ - public User(@NotNull UUID uuid, @NotNull String name) { - this(uuid, name, UserTime.ZERO); - } - - /** - * Returns the unique identifier of this user. - * - * @return player's UUID (never null) - */ - @NotNull - public UUID getUuid() { - return this.uuid; - } - - /** - * Returns the last known player name. - * - * @return name as a non-null String - */ - @NotNull - public String getName() { - return this.name; - } - - /** - * Updates the stored player name. - * - * @param name the new name (non-null, non-blank) - * @throws NullPointerException if name is null - * @throws IllegalArgumentException if name is blank - */ - public void setName(@NotNull String name) { - Objects.requireNonNull(name, "name cannot be null"); - if (name.trim().isEmpty()) { - throw new IllegalArgumentException("name cannot be blank"); - } - this.name = name; - } - - /** - * Returns the total accumulated playtime as an immutable {@link UserTime} object. - * - * @return playtime value (never null) - */ - @NotNull - public UserTime getPlaytime() { - return UserTime.ofMillis(playtimeMillis.get()); - } - - /** - * Replaces the stored playtime with a new value. - * - * @param playtime the new playtime (must not be null) - * @throws NullPointerException if playtime is null - */ - public void setPlaytime(@NotNull UserTime playtime) { - Objects.requireNonNull(playtime, "playtime cannot be null"); - playtimeMillis.set(playtime.millis()); - } - - /** - * Users are equal if and only if their UUIDs match. - */ - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof User other)) return false; - return uuid.equals(other.uuid); - } - - /** - * Hash code is based solely on UUID. - */ - @Override - public int hashCode() { - return uuid.hashCode(); - } - - /** - * Returns a concise diagnostic string representation. - */ - @Override - public String toString() { - return "User{" + - "uuid=" + uuid + - ", name='" + name + '\'' + - ", playtimeMillis=" + playtimeMillis.get() + - '}'; - } -} diff --git a/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserDeleteResult.java b/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserDeleteResult.java deleted file mode 100644 index 01e7fa5..0000000 --- a/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserDeleteResult.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.imdmk.playtime.user; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * Immutable result container representing the outcome of a user deletion attempt. - * - *

This record provides both contextual information: - * the {@link User} instance (if it existed and was deleted) and a - * {@link UserDeleteStatus} value describing the operation result.

- * - *

Usage: Always check {@link #status()} to determine the deletion outcome. - * {@link #user()} may be {@code null} if the user was not found or the operation failed.

- * - * @param user the deleted user instance, or {@code null} if the user did not exist or was not deleted - * @param status non-null result status representing the outcome of the deletion - * - * @see User - * @see UserDeleteStatus - */ -public record UserDeleteResult(@Nullable User user, @NotNull UserDeleteStatus status) { - - /** - * Indicates whether the deletion succeeded and the user actually existed. - *

- * This method is equivalent to checking: - *

{@code user != null && status == UserDeleteStatus.DELETED}
- * - * @return {@code true} if the user was successfully deleted; {@code false} otherwise - */ - public boolean isSuccess() { - return this.user != null && this.status == UserDeleteStatus.DELETED; - } -} diff --git a/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserDeleteStatus.java b/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserDeleteStatus.java deleted file mode 100644 index 8c87147..0000000 --- a/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserDeleteStatus.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.imdmk.playtime.user; - -/** - * Enumerates all possible outcomes of a user deletion request. - * - *

This enum is typically returned as part of a {@link UserDeleteResult} - * to describe whether the deletion succeeded, the user was missing, or - * an internal failure occurred during the operation.

- * - *

Usage: Used primarily by {@code UserService} or repository - * implementations to standardize deletion responses.

- * - * @see UserDeleteResult - * @see User - */ -public enum UserDeleteStatus { - - /** - * The user existed and was successfully removed from persistent storage. - */ - DELETED, - - /** - * The user was not present in the data source at the time of deletion. - */ - NOT_FOUND, - - /** - * The deletion operation failed due to an unexpected exception, - * connectivity issue, or database constraint violation. - */ - FAILED -} diff --git a/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserSaveReason.java b/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserSaveReason.java deleted file mode 100644 index 37e44a6..0000000 --- a/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserSaveReason.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.github.imdmk.playtime.user; - -/** - * Describes the context in which a {@link User} instance is persisted. - * - *

These reasons help services, repositories, logging and auditing systems - * understand why a save operation took place.

- * - *

Typical usage: passed to {@code UserService#save(User, UserSaveReason)} - * to provide semantic context for persistence logic.

- * - * @see User - * @see UserService - */ -public enum UserSaveReason { - - /** - * The player joined the server — user data is loaded or created. - */ - PLAYER_JOIN, - - /** - * The player left the server — user data should be persisted. - */ - PLAYER_LEAVE, - - /** - * An administrator explicitly set the user's playtime via command. - */ - SET_COMMAND, - - /** - * An administrator reset the user's playtime via command. - */ - RESET_COMMAND, - - /** - * The user's data was persisted by a scheduled task - * (e.g., automatic save every 5 minutes). - */ - SCHEDULED_SAVE, - - /** - * The user's playtime was reset by a GUI action (e.g., button click). - */ - GUI_RESET_CLICK -} diff --git a/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserService.java b/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserService.java deleted file mode 100644 index b3ab8e5..0000000 --- a/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserService.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.github.imdmk.playtime.user; - -import org.jetbrains.annotations.NotNull; - -import java.util.Collection; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; - -/** - * High-level service for accessing and managing {@link User} data. - *

- * Provides both cache-only (synchronous, safe for main-thread use) - * and asynchronous (database-backed) operations. - *

- * Implementations are expected to handle caching, persistence, and - * consistency automatically. - */ -public interface UserService { - - /** - * Finds a user by their unique UUID from the in-memory cache only. - *

- * This method is non-blocking and safe to call from the main server thread. - * - * @param uuid the user's UUID - * @return an {@link Optional} containing the user if present in cache, - * or empty if not found - */ - @NotNull Optional findCachedByUuid(@NotNull UUID uuid); - - /** - * Finds a user by their name from the in-memory cache only. - *

- * This method is non-blocking and safe to call from the main server thread. - * - * @param name the user's name (case-insensitive, depending on implementation) - * @return an {@link Optional} containing the user if present in cache, - * or empty if not found - */ - @NotNull Optional findCachedByName(@NotNull String name); - - /** - * Returns an unmodifiable snapshot of all users currently cached in memory. - *

- * This collection reflects a moment-in-time view and is not updated dynamically. - * Safe to call from the main thread. - * - * @return a collection of cached {@link User} objects - */ - @NotNull Collection getCachedUsers(); - - /** - * Asynchronously finds a user by their UUID, using cache as the primary source - * and the database as fallback. - * - * @param uuid the user's UUID - * @return a {@link CompletableFuture} containing an {@link Optional} user - * when the lookup completes - */ - @NotNull CompletableFuture> findByUuid(@NotNull UUID uuid); - - /** - * Asynchronously finds a user by their name, using cache as the primary source - * and the database as fallback. - * - * @param name the user's name (case-insensitive, depending on implementation) - * @return a {@link CompletableFuture} containing an {@link Optional} user - * when the lookup completes - */ - @NotNull CompletableFuture> findByName(@NotNull String name); - - /** - * Retrieves a list of the top users sorted by spent playtime in descending order. - *

- * The result may come from the in-memory - * cached leaderboard or trigger an asynchronous refresh when the cache has expired - * or does not satisfy the requested limit. - *

- * - * @param limit the maximum number of users to return; values ≤ 0 yield an empty list - * @return a {@link CompletableFuture} that completes with a list of users ordered - * by spent time descending, either from cache or freshly queried from the repository - */ - @NotNull CompletableFuture> findTopByPlayTime(int limit); - - /** - * Asynchronously saves a user to the underlying database and updates the cache. - *

- * If the user already exists, their data is updated. - * - * @param user the user to save - * @param reason the reason of save - * @return a {@link CompletableFuture} containing the saved user - */ - @NotNull CompletableFuture save(@NotNull User user, @NotNull UserSaveReason reason); - - /** - * Asynchronously deletes a user by their UUID from both the database and cache. - * - * @param uuid the UUID of the user to delete - * @return a {@link CompletableFuture} completing with {@code true} if the user was deleted, - * or {@code false} if no such user existed - */ - @NotNull CompletableFuture deleteByUuid(@NotNull UUID uuid); - - /** - * Asynchronously deletes a user by their name from both the database and cache. - * - * @param name the name of the user to delete - * @return a {@link CompletableFuture} completing with {@code true} if the user was deleted, - * or {@code false} if no such user existed - */ - @NotNull CompletableFuture deleteByName(@NotNull String name); -} diff --git a/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserTime.java b/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserTime.java deleted file mode 100644 index 2dc8502..0000000 --- a/playtime-api/src/main/java/com/github/imdmk/playtime/user/UserTime.java +++ /dev/null @@ -1,254 +0,0 @@ -package com.github.imdmk.playtime.user; - -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.NotNull; - -import java.io.Serial; -import java.io.Serializable; -import java.time.Duration; -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -/** - * Immutable value object representing a duration of time measured in milliseconds. - * - *

This record provides convenient conversions between milliseconds, seconds, - * Bukkit ticks (1 tick = 50 ms), and {@link Duration}, as well as arithmetic - * and comparison utilities for working with user playtime or uptime data.

- * - *

Design notes:

- *
    - *
  • This class enforces non-negative values — negative durations are not allowed.
  • - *
  • All operations return new immutable instances; this class is thread-safe and - * safe for concurrent use.
  • - *
  • Overflow conditions in arithmetic methods trigger {@link ArithmeticException} - * to prevent silent wrap-around.
  • - *
- * - * @param millis the milliseconds of time - * - * @see Duration - * @see User - */ -public record UserTime(long millis) implements Comparable, Serializable { - - @Serial private static final long serialVersionUID = 1L; - - /** Constant representing zero time. */ - public static final UserTime ZERO = new UserTime(0L); - - /** Number of milliseconds in a single Bukkit tick (20 ticks = 1 second). */ - private static final long MILLIS_PER_TICK = 50L; - - /** Number of milliseconds in one second. */ - private static final long MILLIS_PER_SECOND = 1_000L; - - /** - * Primary constructor that validates the provided time value. - * - * @param millis total duration in milliseconds (must be ≥ 0) - * @throws IllegalArgumentException if {@code millis} is negative - */ - public UserTime { - if (millis < 0L) { - throw new IllegalArgumentException("UserTime millis cannot be negative"); - } - } - - /** - * Creates a {@code UserTime} from raw milliseconds. - * - * @param millis total milliseconds - * @return a new {@code UserTime} instance - */ - @Contract("_ -> new") - public static @NotNull UserTime ofMillis(long millis) { - return new UserTime(millis); - } - - /** - * Creates a {@code UserTime} from duration. - * - * @param duration a duration - * @return a new {@code UserTime} instance - */ - @Contract("_ -> new") - public static @NotNull UserTime ofDuration(@NotNull Duration duration) { - Objects.requireNonNull(duration, "duration cannot be null"); - return ofMillis(duration.toMillis()); - } - - /** - * Creates a {@code UserTime} from seconds. - * - * @param seconds total seconds - * @return a new {@code UserTime} instance - */ - @Contract("_ -> new") - public static @NotNull UserTime ofSeconds(long seconds) { - return new UserTime(seconds * MILLIS_PER_SECOND); - } - - /** - * Creates a {@code UserTime} from Bukkit ticks (1 tick = 50 ms). - * - * @param ticks total ticks - * @return a new {@code UserTime} instance - */ - @Contract("_ -> new") - public static @NotNull UserTime ofTicks(long ticks) { - return new UserTime(ticks * MILLIS_PER_TICK); - } - - /** - * Creates a {@code UserTime} from a {@link Duration}. - * - * @param duration non-null duration to convert - * @return a new {@code UserTime} instance - * @throws NullPointerException if {@code duration} is null - */ - @Contract("_ -> new") - public static @NotNull UserTime from(@NotNull Duration duration) { - Objects.requireNonNull(duration, "duration cannot be null"); - return new UserTime(duration.toMillis()); - } - - /** - * Converts this time to whole seconds (truncated). - * - * @return number of seconds contained in this duration - */ - @Contract(pure = true) - public long toSeconds() { - return TimeUnit.MILLISECONDS.toSeconds(this.millis); - } - - /** - * Converts this time to Bukkit ticks (1 tick = 50 ms). - * - * @return total number of ticks represented by this time - */ - @Contract(pure = true) - public int toTicks() { - return Math.toIntExact(this.millis / MILLIS_PER_TICK); - } - - /** - * Converts this instance to a {@link Duration}. - * - * @return a duration representing the same amount of time - */ - @Contract(pure = true) - public @NotNull Duration toDuration() { - return Duration.ofMillis(this.millis); - } - - /** - * Returns whether this time equals zero. - * - * @return {@code true} if this duration represents zero milliseconds - */ - @Contract(pure = true) - public boolean isZero() { - return this.millis == 0L; - } - - /** - * Adds another {@code UserTime} to this one. - * - * @param other non-null {@code UserTime} to add - * @return new {@code UserTime} representing the sum - * @throws NullPointerException if {@code other} is null - * @throws ArithmeticException if overflow occurs - */ - @Contract(pure = true) - public @NotNull UserTime plus(@NotNull UserTime other) { - Objects.requireNonNull(other, "other UserTime is null"); - return new UserTime(Math.addExact(this.millis, other.millis)); - } - - /** - * Subtracts another {@code UserTime} from this one. - * - * @param other non-null {@code UserTime} to subtract - * @return new {@code UserTime} representing the difference - * @throws NullPointerException if {@code other} is null - * @throws ArithmeticException if overflow occurs - */ - @Contract(pure = true) - public @NotNull UserTime minus(@NotNull UserTime other) { - Objects.requireNonNull(other, "other UserTime is null"); - return new UserTime(Math.subtractExact(this.millis, other.millis)); - } - - /** - * Returns the smaller of this and the given time. - * - * @param other non-null time to compare - * @return the smaller {@code UserTime} instance - */ - @Contract(pure = true) - public @NotNull UserTime min(@NotNull UserTime other) { - Objects.requireNonNull(other, "other UserTime is null"); - return this.millis <= other.millis ? this : other; - } - - /** - * Returns the larger of this and the given time. - * - * @param other non-null time to compare - * @return the larger {@code UserTime} instance - */ - @Contract(pure = true) - public @NotNull UserTime max(@NotNull UserTime other) { - Objects.requireNonNull(other, "other UserTime is null"); - return this.millis >= other.millis ? this : other; - } - - /** - * Compares this {@code UserTime} with another by their millisecond values. - * - * @param o other {@code UserTime} to compare against - * @return negative if this is less, zero if equal, positive if greater - */ - @Override - public int compareTo(@NotNull UserTime o) { - return Long.compare(this.millis, o.millis); - } - - /** - * Checks equality based on millisecond value. - * - * @param o object to compare - * @return {@code true} if the given object is a {@code UserTime} with the same millisecond value - */ - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) return false; - UserTime userTime = (UserTime) o; - return compareTo(userTime) == 0; - } - - /** - * Returns a hash code consistent with {@link #equals(Object)}. - * - * @return hash based on the millisecond value - */ - @Override - public int hashCode() { - return Objects.hashCode(millis); - } - - /** - * Returns a concise string representation suitable for logging or debugging. - * - * @return string in the format {@code "UserTime{millis=X}"} - */ - @Override - @NotNull - public String toString() { - return "UserTime{" + - "millis=" + millis + - '}'; - } -} diff --git a/playtime-api/src/test/java/com/github/imdmk/playtime/UserTimeTest.java b/playtime-api/src/test/java/com/github/imdmk/playtime/PlayTimeTest.java similarity index 57% rename from playtime-api/src/test/java/com/github/imdmk/playtime/UserTimeTest.java rename to playtime-api/src/test/java/com/github/imdmk/playtime/PlayTimeTest.java index a68f325..808d105 100644 --- a/playtime-api/src/test/java/com/github/imdmk/playtime/UserTimeTest.java +++ b/playtime-api/src/test/java/com/github/imdmk/playtime/PlayTimeTest.java @@ -1,6 +1,5 @@ package com.github.imdmk.playtime; -import com.github.imdmk.playtime.user.UserTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -12,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatNullPointerException; -class UserTimeTest { +class PlayTimeTest { @Nested @DisplayName("Construction & factories") @@ -20,40 +19,40 @@ class ConstructionTests { @Test void shouldCreateWithMillis() { - UserTime t = UserTime.ofMillis(1500); + PlayTime t = PlayTime.ofMillis(1500); assertThat(t.millis()).isEqualTo(1500); } @Test void shouldRejectNegativeMillis() { assertThatIllegalArgumentException() - .isThrownBy(() -> new UserTime(-1)) + .isThrownBy(() -> new PlayTime(-1)) .withMessageContaining("negative"); } @Test void shouldCreateFromSeconds() { - UserTime t = UserTime.ofSeconds(2); + PlayTime t = PlayTime.of(Duration.ofSeconds(2)); assertThat(t.millis()).isEqualTo(2000); } @Test void shouldCreateFromTicks() { - UserTime t = UserTime.ofTicks(10); + PlayTime t = PlayTime.ofTicks(10); assertThat(t.millis()).isEqualTo(10 * 50); } @Test void shouldCreateFromDuration() { Duration d = Duration.ofMillis(1234); - UserTime t = UserTime.from(d); + PlayTime t = PlayTime.of(d); assertThat(t.millis()).isEqualTo(1234); } @Test void shouldRejectNullDuration() { assertThatNullPointerException() - .isThrownBy(() -> UserTime.from(null)) + .isThrownBy(() -> PlayTime.of(null)) .withMessageContaining("duration"); } } @@ -64,19 +63,19 @@ class ConversionTests { @Test void shouldConvertToSeconds() { - UserTime t = UserTime.ofMillis(2500); + PlayTime t = PlayTime.ofMillis(2500); assertThat(t.toSeconds()).isEqualTo(2); } @Test void shouldConvertToTicks() { - UserTime t = UserTime.ofMillis(250); + PlayTime t = PlayTime.ofMillis(250); assertThat(t.toTicks()).isEqualTo(5); // 250 / 50 } @Test void shouldConvertToDuration() { - UserTime t = UserTime.ofMillis(500); + PlayTime t = PlayTime.ofMillis(500); assertThat(t.toDuration()).isEqualTo(Duration.ofMillis(500)); } } @@ -87,15 +86,15 @@ class ArithmeticTests { @Test void shouldAddUserTimes() { - UserTime a = UserTime.ofMillis(1000); - UserTime b = UserTime.ofMillis(2000); - assertThat(a.plus(b)).isEqualTo(UserTime.ofMillis(3000)); + PlayTime a = PlayTime.ofMillis(1000); + PlayTime b = PlayTime.ofMillis(2000); + assertThat(a.plus(b)).isEqualTo(PlayTime.ofMillis(3000)); } @Test void shouldThrowOnAddOverflow() { - UserTime a = UserTime.ofMillis(Long.MAX_VALUE); - UserTime b = UserTime.ofMillis(1); + PlayTime a = PlayTime.ofMillis(Long.MAX_VALUE); + PlayTime b = PlayTime.ofMillis(1); assertThatExceptionOfType(ArithmeticException.class) .isThrownBy(() -> a.plus(b)); @@ -103,14 +102,14 @@ void shouldThrowOnAddOverflow() { @Test void shouldSubtractUserTimes() { - UserTime a = UserTime.ofMillis(3000); - UserTime b = UserTime.ofMillis(1000); - assertThat(a.minus(b)).isEqualTo(UserTime.ofMillis(2000)); + PlayTime a = PlayTime.ofMillis(3000); + PlayTime b = PlayTime.ofMillis(1000); + assertThat(a.minus(b)).isEqualTo(PlayTime.ofMillis(2000)); } @Test void shouldRejectNullInPlus() { - UserTime t = UserTime.ZERO; + PlayTime t = PlayTime.ZERO; assertThatNullPointerException() .isThrownBy(() -> t.plus(null)); @@ -118,61 +117,22 @@ void shouldRejectNullInPlus() { @Test void shouldRejectNullInMinus() { - UserTime t = UserTime.ZERO; + PlayTime t = PlayTime.ZERO; assertThatNullPointerException() .isThrownBy(() -> t.minus(null)); } } - @Nested - @DisplayName("Min/Max") - class MinMaxTests { - - @Test - void shouldReturnMin() { - UserTime a = UserTime.ofMillis(500); - UserTime b = UserTime.ofMillis(1000); - - assertThat(a.min(b)).isEqualTo(a); - assertThat(b.min(a)).isEqualTo(a); - } - - @Test - void shouldReturnMax() { - UserTime a = UserTime.ofMillis(500); - UserTime b = UserTime.ofMillis(1000); - - assertThat(a.max(b)).isEqualTo(b); - assertThat(b.max(a)).isEqualTo(b); - } - - @Test - void shouldRejectNullInMin() { - UserTime a = UserTime.ZERO; - - assertThatNullPointerException() - .isThrownBy(() -> a.min(null)); - } - - @Test - void shouldRejectNullInMax() { - UserTime a = UserTime.ZERO; - - assertThatNullPointerException() - .isThrownBy(() -> a.max(null)); - } - } - @Nested @DisplayName("Comparison") class ComparisonTests { @Test void shouldCompareByMillis() { - UserTime a = UserTime.ofMillis(100); - UserTime b = UserTime.ofMillis(200); - UserTime c = UserTime.ofMillis(100); + PlayTime a = PlayTime.ofMillis(100); + PlayTime b = PlayTime.ofMillis(200); + PlayTime c = PlayTime.ofMillis(100); assertThat(a.compareTo(b)).isNegative(); assertThat(b.compareTo(a)).isPositive(); @@ -186,8 +146,8 @@ class EqualityTests { @Test void shouldBeEqualWhenMillisIsSame() { - UserTime a = UserTime.ofMillis(500); - UserTime b = UserTime.ofMillis(500); + PlayTime a = PlayTime.ofMillis(500); + PlayTime b = PlayTime.ofMillis(500); assertThat(a).isEqualTo(b); assertThat(a.hashCode()).isEqualTo(b.hashCode()); @@ -195,8 +155,8 @@ void shouldBeEqualWhenMillisIsSame() { @Test void shouldNotBeEqualWhenMillisDiffers() { - UserTime a = UserTime.ofMillis(500); - UserTime b = UserTime.ofMillis(300); + PlayTime a = PlayTime.ofMillis(500); + PlayTime b = PlayTime.ofMillis(300); assertThat(a).isNotEqualTo(b); } @@ -204,13 +164,13 @@ void shouldNotBeEqualWhenMillisDiffers() { @Test void shouldIdentifyZero() { - assertThat(UserTime.ZERO.isZero()).isTrue(); - assertThat(UserTime.ofMillis(1).isZero()).isFalse(); + assertThat(PlayTime.ZERO.isZero()).isTrue(); + assertThat(PlayTime.ofMillis(1).isZero()).isFalse(); } @Test void toStringShouldContainMillis() { - UserTime t = UserTime.ofMillis(1234); + PlayTime t = PlayTime.ofMillis(1234); assertThat(t.toString()).contains("millis=1234"); } } diff --git a/playtime-api/src/test/java/com/github/imdmk/playtime/UserTest.java b/playtime-api/src/test/java/com/github/imdmk/playtime/UserTest.java deleted file mode 100644 index 758d654..0000000 --- a/playtime-api/src/test/java/com/github/imdmk/playtime/UserTest.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.github.imdmk.playtime; - -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserTime; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.assertj.core.api.Assertions.assertThatNullPointerException; - -class UserTest { - - @Nested - @DisplayName("Constructor") - class ConstructorTests { - - @Test - void shouldInitializeFieldsCorrectly() { - UUID uuid = UUID.randomUUID(); - UserTime time = UserTime.ofMillis(5000); - - User user = new User(uuid, "Player", time); - - assertThat(user.getUuid()).isEqualTo(uuid); - assertThat(user.getName()).isEqualTo("Player"); - assertThat(user.getPlaytime()).isEqualTo(time); - } - - @Test - void shouldCreateUserWithZeroPlaytime() { - User user = new User(UUID.randomUUID(), "Player"); - - assertThat(user.getPlaytime()).isEqualTo(UserTime.ZERO); - } - - @Test - void shouldThrowWhenNameIsNull() { - UUID uuid = UUID.randomUUID(); - - assertThatNullPointerException() - .isThrownBy(() -> new User(uuid, null, UserTime.ZERO)) - .withMessageContaining("name"); - } - - @Test - void shouldThrowWhenPlaytimeIsNull() { - UUID uuid = UUID.randomUUID(); - - assertThatNullPointerException() - .isThrownBy(() -> new User(uuid, "Player", null)) - .withMessageContaining("playtime"); - } - } - - @Nested - @DisplayName("Name mutation") - class NameTests { - - @Test - void shouldUpdateName() { - User user = new User(UUID.randomUUID(), "Old"); - - user.setName("New"); - - assertThat(user.getName()).isEqualTo("New"); - } - - @Test - void shouldRejectNullName() { - User user = new User(UUID.randomUUID(), "Old"); - - assertThatNullPointerException() - .isThrownBy(() -> user.setName(null)) - .withMessageContaining("name"); - } - - @Test - void shouldRejectBlankName() { - User user = new User(UUID.randomUUID(), "Old"); - - assertThatIllegalArgumentException() - .isThrownBy(() -> user.setName(" ")) - .withMessageContaining("blank"); - } - } - - @Nested - @DisplayName("Playtime mutation") - class PlaytimeTests { - - @Test - void shouldReturnCurrentPlaytimeAsUserTime() { - User user = new User(UUID.randomUUID(), "Player", UserTime.ofMillis(1000)); - - assertThat(user.getPlaytime().millis()).isEqualTo(1000); - } - - @Test - void shouldSetNewPlaytime() { - User user = new User(UUID.randomUUID(), "Player"); - - user.setPlaytime(UserTime.ofMillis(12345)); - - assertThat(user.getPlaytime().millis()).isEqualTo(12345); - } - - @Test - void shouldRejectNullPlaytime() { - User user = new User(UUID.randomUUID(), "Player"); - - assertThatNullPointerException() - .isThrownBy(() -> user.setPlaytime(null)) - .withMessageContaining("playtime"); - } - } - - @Nested - @DisplayName("Equality & Hashcode") - class EqualityTests { - - @Test - void usersWithSameUuidShouldBeEqual() { - UUID uuid = UUID.randomUUID(); - - User u1 = new User(uuid, "A"); - User u2 = new User(uuid, "B"); - - assertThat(u1).isEqualTo(u2); - assertThat(u1).hasSameHashCodeAs(u2); - } - - @Test - void usersWithDifferentUuidShouldNotBeEqual() { - User u1 = new User(UUID.randomUUID(), "A"); - User u2 = new User(UUID.randomUUID(), "A"); - - assertThat(u1).isNotEqualTo(u2); - } - } - - @Test - void toStringShouldContainKeyInformation() { - User user = new User(UUID.randomUUID(), "Player", UserTime.ofMillis(100)); - - String str = user.toString(); - - assertThat(str) - .contains("uuid=") - .contains("name='Player'") - .contains("playtimeMillis=100"); - } -} diff --git a/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/PlayTimeChangedEvent.java b/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/PlayTimeChangedEvent.java new file mode 100644 index 0000000..658e016 --- /dev/null +++ b/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/PlayTimeChangedEvent.java @@ -0,0 +1,55 @@ +package com.github.imdmk.playtime; + +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public final class PlayTimeChangedEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + private static final boolean ASYNC = true; + + private final UUID playerId; + private final PlayTime newTime; + private final PlayTime oldTime; + + public PlayTimeChangedEvent( + @NotNull UUID playerId, + @NotNull PlayTime newTime, + @NotNull PlayTime oldTime + ) { + super(ASYNC); + this.playerId = playerId; + this.oldTime = oldTime; + this.newTime = newTime; + } + + @NotNull + public UUID getPlayerId() { + return playerId; + } + + @NotNull + public PlayTime getNewTime() { + return newTime; + } + + @NotNull + public PlayTime getOldTime() { + return oldTime; + } + + @NotNull + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + @NotNull + public static HandlerList getHandlerList() { + return HANDLERS; + } + +} diff --git a/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/UserDeleteEvent.java b/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/UserDeleteEvent.java deleted file mode 100644 index 4459b8b..0000000 --- a/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/UserDeleteEvent.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.github.imdmk.playtime; - -import com.github.imdmk.playtime.user.UserDeleteResult; -import org.bukkit.event.Event; -import org.bukkit.event.HandlerList; -import org.jetbrains.annotations.NotNull; - -import java.util.Objects; - -/** - * Fired after a user deletion attempt completes. - *

- * Carries a {@link UserDeleteResult} with the deleted user snapshot (if any) - * and the {@link com.github.imdmk.playtime.user.UserDeleteStatus} outcome. - *

- *

Threading: Dispatched synchronously on the main server thread.

- */ -public final class UserDeleteEvent extends Event { - - private static final HandlerList HANDLERS = new HandlerList(); - private static final boolean ASYNC = true; - - private final UserDeleteResult result; - - /** - * Creates a new {@code UserDeleteEvent}. - * - * @param result non-null deletion result - */ - public UserDeleteEvent(@NotNull UserDeleteResult result) { - super(ASYNC); - this.result = Objects.requireNonNull(result, "result cannot be null"); - } - - /** - * Returns the result of the deletion operation, including the status and an - * optional snapshot of the deleted user (if available). - * - * @return non-null {@link UserDeleteResult} representing the outcome of the deletion - */ - public @NotNull UserDeleteResult getResult() { - return this.result; - } - - /** - * Returns the handler list used internally by Bukkit to register and manage - * listeners for this event. - * - * @return non-null static {@link HandlerList} for this event type - */ - @Override - public @NotNull HandlerList getHandlers() { - return HANDLERS; - } - - /** - * Returns the static handler list for this event type. - *

- * This method is required by the Bukkit event framework and allows Bukkit - * to correctly map event handlers to this event class. - * - * @return non-null static {@link HandlerList} - */ - public static @NotNull HandlerList getHandlerList() { - return HANDLERS; - } - -} diff --git a/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/UserPreSaveEvent.java b/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/UserPreSaveEvent.java deleted file mode 100644 index 3b4ebd8..0000000 --- a/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/UserPreSaveEvent.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.github.imdmk.playtime; - -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserSaveReason; -import org.bukkit.event.Event; -import org.bukkit.event.HandlerList; -import org.jetbrains.annotations.NotNull; - -import java.util.Objects; - -/** - * Event fired immediately before a {@link User} instance is persisted - * to the database by the plugin. - * - *

This event serves as a pre-save hook, allowing listeners to - * inspect or modify the {@link User} object before it is written to storage. - * Any changes made to the user during this event will be included in the - * final persisted representation.

- * - *

Note: This event is not cancellable. It is strictly intended - * for mutation or inspection of the user object before saving. If cancellation - * behavior is required in the future, a dedicated cancellable event should be introduced.

- * - *

Thread safety: This event is always fired synchronously on the - * main server thread.

- */ -public final class UserPreSaveEvent extends Event { - - private static final HandlerList HANDLERS = new HandlerList(); - private static final boolean ASYNC = false; - - private final User user; - private final UserSaveReason reason; - - /** - * Creates a new {@code UserPreSaveEvent}. - * - * @param user the user about to be saved (non-null) - * @param reason the context in which the save operation was triggered (non-null) - */ - public UserPreSaveEvent(@NotNull User user, @NotNull UserSaveReason reason) { - super(ASYNC); - this.user = Objects.requireNonNull(user, "user cannot be null"); - this.reason = Objects.requireNonNull(reason, "reason cannot be null"); - } - - /** - * Returns the {@link User} instance that will be persisted. - *

Modifying this object will affect the data written to storage.

- * - * @return the user associated with this event - */ - public @NotNull User getUser() { - return this.user; - } - - /** - * Returns the reason for the save operation. - * - * @return a {@link UserSaveReason} describing why the user is being saved - */ - public @NotNull UserSaveReason getReason() { - return this.reason; - } - - /** - * Returns the static handler list for this event type. - * - * @return the handler list of this event. - */ - @Override - public @NotNull HandlerList getHandlers() { - return HANDLERS; - } - - /** - * Returns the static handler list for this event type. - * - * @return the list of handlers for this event. - */ - public static @NotNull HandlerList getHandlerList() { - return HANDLERS; - } -} diff --git a/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/UserSaveEvent.java b/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/UserSaveEvent.java deleted file mode 100644 index 35befe5..0000000 --- a/playtime-bukkit-api/src/main/java/com/github/imdmk/playtime/UserSaveEvent.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.github.imdmk.playtime; - -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserSaveReason; -import org.bukkit.event.Event; -import org.bukkit.event.HandlerList; -import org.jetbrains.annotations.NotNull; - -import java.util.Objects; - -/** - * Called whenever a {@link User} instance is saved by the plugin. - * - *

This event is fired after user data has been persisted. - * It can be canceled to prevent later operations that depend on the save, - * if applicable within the plugin's logic.

- * - *

Thread safety: This event is always fired synchronously on the main server thread.

- */ -public final class UserSaveEvent extends Event { - - private static final HandlerList HANDLERS = new HandlerList(); - private static final boolean ASYNC = false; - - private final User user; - private final UserSaveReason reason; - - /** - * Constructs a new {@code UserSaveEvent}. - * - * @param user the user that was saved (non-null) - * @param reason the reason of user save - */ - public UserSaveEvent(@NotNull User user, @NotNull UserSaveReason reason) { - super(ASYNC); - this.user = Objects.requireNonNull(user, "user cannot be null"); - this.reason = Objects.requireNonNull(reason, "reason cannot be null"); - } - - /** - * Returns the {@link User} associated with this event. - * - * @return non-null user involved in this event - */ - public @NotNull User getUser() { - return this.user; - } - - /** - * Returns the reason why this save operation was triggered. - * - * @return non-null {@link UserSaveReason} describing the save cause - */ - public @NotNull UserSaveReason getReason() { - return this.reason; - } - - /** - * Returns the handler list used internally by Bukkit to manage event listeners. - * - * @return non-null static {@link HandlerList} for this event type - */ - @Override - public @NotNull HandlerList getHandlers() { - return HANDLERS; - } - - /** - * Returns the static handler list for this event type. - *

- * This method is required by the Bukkit event system and is used - * to register and manage listeners for this event. - * - * @return non-null static {@link HandlerList} - */ - public static @NotNull HandlerList getHandlerList() { - return HANDLERS; - } -} diff --git a/playtime-core/build.gradle.kts b/playtime-core/build.gradle.kts index 918ecdb..4227ce0 100644 --- a/playtime-core/build.gradle.kts +++ b/playtime-core/build.gradle.kts @@ -13,6 +13,9 @@ dependencies { // Dynamic dependency loader implementation("com.alessiodp.libby:libby-bukkit:2.0.0-SNAPSHOT") + // Reflections + implementation("io.github.classgraph:classgraph:4.8.184") + // Multification implementation("com.eternalcode:multification-bukkit:1.2.3") implementation("com.eternalcode:multification-okaeri:1.2.3") diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/PlayTimeApiAdapter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/PlayTimeApiAdapter.java deleted file mode 100644 index c66e1be..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/PlayTimeApiAdapter.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.imdmk.playtime; - -import com.github.imdmk.playtime.user.UserService; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -@Inject -record PlayTimeApiAdapter( - @NotNull UserService userService, - @NotNull PlaytimeService playtimeService) implements PlayTimeApi { - - @Override - public @NotNull UserService userService() { - return userService; - } - - @Override - public @NotNull PlaytimeService playtimeService() { - return playtimeService; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/PlayTimeBinder.java b/playtime-core/src/main/java/com/github/imdmk/playtime/PlayTimeBinder.java deleted file mode 100644 index 1bacd48..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/PlayTimeBinder.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.github.imdmk.playtime; - -import com.github.imdmk.playtime.infrastructure.injector.Bind; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.Injector; -import org.panda_lang.utilities.inject.Resources; - -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; - -/** - * Discovers fields in {@link PlayTimePlugin} annotated with {@link Bind} - * and registers their instances into the DI {@link Resources}. - *

- * This approach keeps {@link PlayTimePlugin} focused on lifecycle/bootstrap logic - * while delegating dependency wiring to a dedicated, reflection-based binder. - * Only non-static fields with {@code @Bind} are processed. - */ -final class PlayTimeBinder { - - private final PlayTimePlugin core; - - /** - * Creates a new binder for the given plugin instance. - * - * @param core the plugin root object providing core dependencies - */ - PlayTimeBinder(@NotNull PlayTimePlugin core) { - this.core = Validator.notNull(core, "core"); - } - - /** - * Scans the {@link PlayTimePlugin} class hierarchy, locates fields annotated with - * {@link Bind}, reads their values, and registers them into the provided - * {@link Resources} instance. - * - * @param resources DI container resources to bind into - */ - void bind(@NotNull Resources resources) { - Validator.notNull(resources, "resources"); - - Class type = core.getClass(); - - while (type != null && type != Object.class) { - for (Field field : type.getDeclaredFields()) { - if (!field.isAnnotationPresent(Bind.class)) { - continue; - } - - if (Modifier.isStatic(field.getModifiers())) { - continue; - } - - field.setAccessible(true); - - final Object value; - try { - value = field.get(core); - } catch (IllegalAccessException e) { - throw new IllegalStateException("Failed to access @BindCore field: " + field, e); - } - - if (value == null) { - throw new IllegalStateException("@BindCore field " + field + " is null during binding"); - } - - resources.on(field.getType()).assignInstance(value); - } - - type = type.getSuperclass(); - } - - // Provide Injector via lazy supplier - resources.on(Injector.class).assignInstance(() -> core.injector); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/PlayTimePlugin.java b/playtime-core/src/main/java/com/github/imdmk/playtime/PlayTimePlugin.java index a6311bf..52f7d4b 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/PlayTimePlugin.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/PlayTimePlugin.java @@ -1,211 +1,63 @@ package com.github.imdmk.playtime; -import com.eternalcode.multification.notice.Notice; -import com.github.imdmk.playtime.config.ConfigManager; -import com.github.imdmk.playtime.config.ConfigSection; -import com.github.imdmk.playtime.config.InjectorConfigBinder; -import com.github.imdmk.playtime.config.PluginConfig; -import com.github.imdmk.playtime.infrastructure.database.DatabaseConfig; -import com.github.imdmk.playtime.infrastructure.database.DatabaseManager; -import com.github.imdmk.playtime.infrastructure.database.repository.RepositoryContext; -import com.github.imdmk.playtime.infrastructure.database.repository.RepositoryManager; -import com.github.imdmk.playtime.infrastructure.injector.Bind; -import com.github.imdmk.playtime.infrastructure.module.Module; -import com.github.imdmk.playtime.infrastructure.module.ModuleContext; -import com.github.imdmk.playtime.infrastructure.module.ModuleInitializer; -import com.github.imdmk.playtime.infrastructure.module.ModuleRegistry; -import com.github.imdmk.playtime.message.MessageConfig; -import com.github.imdmk.playtime.message.MessageService; -import com.github.imdmk.playtime.platform.events.BukkitEventCaller; -import com.github.imdmk.playtime.platform.events.BukkitListenerRegistrar; -import com.github.imdmk.playtime.platform.gui.GuiRegistry; -import com.github.imdmk.playtime.platform.litecommands.InvalidUsageHandlerImpl; -import com.github.imdmk.playtime.platform.litecommands.MissingPermissionsHandlerImpl; -import com.github.imdmk.playtime.platform.litecommands.NoticeResultHandlerImpl; +import com.github.imdmk.playtime.feature.playtime.PlayTimeApiAdapter; +import com.github.imdmk.playtime.injector.ComponentManager; +import com.github.imdmk.playtime.injector.processor.ComponentProcessors; +import com.github.imdmk.playtime.injector.subscriber.LocalPublisher; +import com.github.imdmk.playtime.injector.subscriber.Publisher; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeInitializeEvent; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeShutdownEvent; import com.github.imdmk.playtime.platform.logger.BukkitPluginLogger; import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.platform.placeholder.adapter.PlaceholderAdapter; -import com.github.imdmk.playtime.platform.placeholder.adapter.PlaceholderAdapterFactory; -import com.github.imdmk.playtime.platform.scheduler.BukkitTaskScheduler; -import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; -import com.github.imdmk.playtime.shared.time.Durations; -import com.github.imdmk.playtime.shared.validate.Validator; import com.google.common.base.Stopwatch; -import dev.rollczi.litecommands.LiteCommands; -import dev.rollczi.litecommands.LiteCommandsBuilder; -import dev.rollczi.litecommands.bukkit.LiteBukkitFactory; -import net.kyori.adventure.platform.bukkit.BukkitAudiences; -import org.bstats.bukkit.Metrics; import org.bukkit.Server; -import org.bukkit.command.CommandSender; import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; import org.jetbrains.annotations.NotNull; import org.panda_lang.utilities.inject.DependencyInjection; import org.panda_lang.utilities.inject.Injector; -import java.sql.SQLException; -import java.time.Duration; -import java.util.List; -import java.util.concurrent.ExecutorService; +import java.io.File; +import java.util.concurrent.TimeUnit; -/** - * Main runtime bootstrap for the PlayTime plugin. - * Threading note: heavy I/O offloaded to {@link ExecutorService}. - */ final class PlayTimePlugin { - private static final String PREFIX = "AdvancedPlayTime"; - private static final int PLUGIN_METRICS_ID = 19362; - - @Bind private final ModuleRegistry moduleRegistry = new ModuleRegistry(); - - @Bind private final Plugin plugin; - @Bind private final PluginLogger logger; - @Bind private final Server server; - @Bind private final ExecutorService executor; - - @Bind private ConfigManager configManager; - - @Bind private DatabaseManager databaseManager; - @Bind private RepositoryContext repositoryContext; - @Bind private RepositoryManager repositoryManager; - - @Bind private MessageService messageService; - @Bind private TaskScheduler taskScheduler; - @Bind private BukkitEventCaller eventCaller; - @Bind private BukkitListenerRegistrar listenerRegistrar; - @Bind private GuiRegistry guiRegistry; - @Bind private PlaceholderAdapter placeholderAdapter; - - @Bind private LiteCommandsBuilder liteCommandsBuilder; - @Bind private LiteCommands liteCommands; - - private Metrics metrics; - - Injector injector; - - PlayTimePlugin( - @NotNull Plugin plugin, - @NotNull Server server, - @NotNull PluginLogger logger, - @NotNull ExecutorService executor - ) { - this.plugin = Validator.notNull(plugin, "plugin"); - this.server = Validator.notNull(server, "server"); - this.logger = Validator.notNull(logger, "logger"); - this.executor = Validator.notNull(executor, "executorService"); - } - - PlayTimePlugin(@NotNull Plugin plugin, @NotNull ExecutorService executor) { - this(plugin, plugin.getServer(), new BukkitPluginLogger(plugin), executor); - } - - void enable( - @NotNull List> enabledConfigs, - @NotNull List> enabledModules - ) { - Validator.notNull(enabledConfigs, "enabledConfigs"); - Validator.notNull(enabledModules, "enabled modules"); + private final Publisher publisher; + PlayTimePlugin(@NotNull Plugin plugin) { final Stopwatch stopwatch = Stopwatch.createStarted(); - - // Configuration - configManager = new ConfigManager(logger, plugin.getDataFolder()); - configManager.createAll(enabledConfigs); - - // Duration format style - final PluginConfig pluginConfig = configManager.require(PluginConfig.class); - Durations.setDefaultFormatStyle(pluginConfig.durationFormatStyle); - - // Database - final DatabaseConfig databaseConfig = configManager.require(DatabaseConfig.class); - databaseManager = new DatabaseManager(plugin, logger, databaseConfig); - - databaseManager.loadDriver(); - try { - databaseManager.connect(); - } catch (SQLException e) { - logger.error(e, "An error occurred while trying to start all repositories. Disabling plugin..."); - plugin.getPluginLoader().disablePlugin(plugin); - throw new IllegalStateException("Repository startup failed", e); - } - - // Infrastructure services - repositoryContext = new RepositoryContext(executor); - repositoryManager = new RepositoryManager(logger); - - final MessageConfig messageConfig = configManager.require(MessageConfig.class); - messageService = new MessageService(messageConfig, BukkitAudiences.create(plugin)); - - taskScheduler = new BukkitTaskScheduler(plugin, server.getScheduler()); - eventCaller = new BukkitEventCaller(server, taskScheduler); - listenerRegistrar = new BukkitListenerRegistrar(plugin); - guiRegistry = new GuiRegistry(); - placeholderAdapter = PlaceholderAdapterFactory.createFor(plugin, server, logger); - - liteCommandsBuilder = LiteBukkitFactory.builder(PREFIX, plugin, server); - liteCommandsBuilder - .invalidUsage(new InvalidUsageHandlerImpl(messageService)) - .missingPermission(new MissingPermissionsHandlerImpl(messageService)) - .result(Notice.class, new NoticeResultHandlerImpl(messageService)); - - // Dependency Injection - injector = DependencyInjection.createInjector(resources -> { - new PlayTimeBinder(this).bind(resources); - InjectorConfigBinder.bind(resources, configManager.getConfigs()); + final PluginLogger logger = new BukkitPluginLogger(plugin); + + final Injector injector = DependencyInjection.createInjector(resources -> { + resources.on(Plugin.class).assignInstance(plugin); + resources.on(Server.class).assignInstance(plugin.getServer()); + resources.on(File.class).assignInstance(plugin.getDataFolder()); + resources.on(PluginLogger.class).assignInstance(logger); + resources.on(BukkitScheduler.class).assignInstance(plugin.getServer().getScheduler()); }); - // Module initialization - final ModuleContext context = injector.newInstance(ModuleContext.class); - final ModuleInitializer initializer = new ModuleInitializer(context, moduleRegistry, injector); + injector.getResources().on(Injector.class).assignInstance(() -> injector); - initializer.loadAndSort(enabledModules); - initializer.bindAll(); - initializer.initAll(); - initializer.registerRepositories(); + this.publisher = new LocalPublisher(injector); - // Start repositories - Validator.ifNotNull(databaseManager.getConnection(), connection -> { - try { - repositoryManager.startAll(connection); - } catch (SQLException e) { - logger.error(e, "An error occurred while trying to start all repositories. Disabling plugin..."); - plugin.getPluginLoader().disablePlugin(plugin); - throw new IllegalStateException("Repository startup failed", e); - } - }); - - // Activate all feature modules - initializer.activateFeatures(); + final ComponentManager componentManager = new ComponentManager(injector, plugin.getClass().getPackageName()) + .addProcessors(ComponentProcessors.defaults(plugin)) + .addPostProcessor(((instance, context) -> this.publisher.subscribe(instance))); - // Build commands - liteCommands = liteCommandsBuilder.build(); + componentManager.scanAll(); + componentManager.processAll(); - // Metrics - metrics = new Metrics(plugin, PLUGIN_METRICS_ID); - - // API - final PlayTimeApiAdapter api = injector.newInstance(PlayTimeApiAdapter.class); + final PlayTimeApi api = injector.newInstance(PlayTimeApiAdapter.class); PlayTimeApiProvider.register(api); - final Duration elapsed = stopwatch.stop().elapsed(); - logger.info("%s plugin enabled in %s ms", PREFIX, elapsed.toMillis()); + this.publisher.publish(new PlayTimeInitializeEvent()); + + final long elapsedMillis = stopwatch.elapsed(TimeUnit.MILLISECONDS); + logger.info("Successfully loaded plugin in " + elapsedMillis + "ms!"); } void disable() { - Validator.ifNotNull(configManager, (manager) -> { - manager.saveAll(); - manager.clearAll(); - }); - Validator.ifNotNull(repositoryManager, RepositoryManager::close); - Validator.ifNotNull(databaseManager, DatabaseManager::shutdown); - Validator.ifNotNull(messageService, MessageService::shutdown); - Validator.ifNotNull(taskScheduler, TaskScheduler::shutdown); - Validator.ifNotNull(liteCommands, LiteCommands::unregister); - Validator.ifNotNull(metrics, Metrics::shutdown); - + this.publisher.publish(new PlayTimeShutdownEvent()); PlayTimeApiProvider.unregister(); - - logger.info("%s plugin disabled successfully.", PREFIX); } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigBinder.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigBinder.java deleted file mode 100644 index f0b2f6f..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigBinder.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.github.imdmk.playtime.config; - -import com.github.imdmk.playtime.shared.validate.Validator; -import eu.okaeri.configs.serdes.OkaeriSerdesPack; -import eu.okaeri.configs.serdes.commons.SerdesCommons; -import eu.okaeri.configs.yaml.snakeyaml.YamlSnakeYamlConfigurer; -import org.jetbrains.annotations.NotNull; - -import java.io.File; - -final class ConfigBinder { - - void bind(@NotNull ConfigSection config, @NotNull File file) { - Validator.notNull(config, "config"); - Validator.notNull(file, "file"); - - final OkaeriSerdesPack serdesPack = config.getSerdesPack(); - final YamlSnakeYamlConfigurer yamlConfigurer = YamlConfigurerFactory.create(); - - config.withConfigurer(yamlConfigurer) - .withSerdesPack(serdesPack) - .withSerdesPack(new SerdesCommons()) - .withBindFile(file) - .withRemoveOrphans(true); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigConfigurer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigConfigurer.java new file mode 100644 index 0000000..1ef369d --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigConfigurer.java @@ -0,0 +1,24 @@ +package com.github.imdmk.playtime.config; + +import eu.okaeri.configs.serdes.OkaeriSerdesPack; +import eu.okaeri.configs.serdes.commons.SerdesCommons; +import eu.okaeri.configs.yaml.snakeyaml.YamlSnakeYamlConfigurer; +import org.jetbrains.annotations.NotNull; + +import java.io.File; + +final class ConfigConfigurer { + + void configure( + @NotNull ConfigSection config, + @NotNull File file, + OkaeriSerdesPack... serdesPacks + ) { + final YamlSnakeYamlConfigurer configurer = new YamlSnakeYamlConfigurer(YamlFactory.create()); + + config.withConfigurer(configurer, serdesPacks) + .withSerdesPack(new SerdesCommons()) + .withBindFile(file) + .withRemoveOrphans(true); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigCreateException.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigCreateException.java new file mode 100644 index 0000000..260727e --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigCreateException.java @@ -0,0 +1,16 @@ +package com.github.imdmk.playtime.config; + +public class ConfigCreateException extends RuntimeException { + + public ConfigCreateException(String message) { + super(message); + } + + public ConfigCreateException(String message, Throwable cause) { + super(message, cause); + } + + public ConfigCreateException(Throwable cause) { + super(cause); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigFactory.java index 2327c7a..e50aad7 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigFactory.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigFactory.java @@ -1,6 +1,5 @@ package com.github.imdmk.playtime.config; -import com.github.imdmk.playtime.shared.validate.Validator; import eu.okaeri.configs.ConfigManager; import eu.okaeri.configs.exception.OkaeriException; import org.jetbrains.annotations.NotNull; @@ -8,12 +7,10 @@ final class ConfigFactory { @NotNull T instantiate(@NotNull Class type) { - Validator.notNull(type, "type"); - try { return ConfigManager.create(type); } catch (OkaeriException e) { - throw new IllegalStateException( + throw new ConfigCreateException( "Failed to instantiate config: " + type.getName(), e ); } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigLifecycle.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigLifecycle.java index 78097f8..6e941ad 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigLifecycle.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigLifecycle.java @@ -1,18 +1,10 @@ package com.github.imdmk.playtime.config; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; import eu.okaeri.configs.exception.OkaeriException; import org.jetbrains.annotations.NotNull; final class ConfigLifecycle { - private final PluginLogger logger; - - ConfigLifecycle(@NotNull PluginLogger logger) { - this.logger = Validator.notNull(logger, "logger"); - } - void initialize(@NotNull ConfigSection config) { config.saveDefaults(); load(config); @@ -22,17 +14,15 @@ void load(@NotNull ConfigSection config) { try { config.load(true); } catch (OkaeriException e) { - logger.error(e, "Failed to load config %s", config.getClass().getSimpleName()); - throw new ConfigAccessException(e); + throw new ConfigAccessException("Failed to load config: " + config.getClass().getSimpleName(), e); } } void save(@NotNull ConfigSection config) { try { config.save(); - } catch (Exception e) { - logger.error(e, "Failed to save config %s", config.getClass().getSimpleName()); - throw new ConfigAccessException(e); + } catch (OkaeriException e) { + throw new ConfigAccessException("Failed to save config: " + config.getClass().getSimpleName(), e); } } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigManager.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigManager.java deleted file mode 100644 index 017ac9c..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigManager.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.github.imdmk.playtime.config; - -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Unmodifiable; - -import java.io.File; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -public final class ConfigManager { - - private final Set configs = ConcurrentHashMap.newKeySet(); - private final Map, ConfigSection> byType = new ConcurrentHashMap<>(); - - private final File dataFolder; - - private final ConfigFactory factory; - private final ConfigBinder binder; - private final ConfigLifecycle lifecycle; - - public ConfigManager(@NotNull PluginLogger logger, @NotNull File dataFolder) { - this.dataFolder = Validator.notNull(dataFolder, "dataFolder"); - - this.factory = new ConfigFactory(); - this.binder = new ConfigBinder(); - this.lifecycle = new ConfigLifecycle(logger); - } - - public @NotNull T create(@NotNull Class type) { - final T config = factory.instantiate(type); - final File file = new File(dataFolder, config.getFileName()); - - binder.bind(config, file); - lifecycle.initialize(config); - - register(type, config); - return config; - } - - public void createAll(@NotNull List> types) { - types.forEach(this::create); - } - - @SuppressWarnings("unchecked") - public T get(@NotNull Class type) { - return (T) byType.get(type); - } - - public @NotNull T require(@NotNull Class type) { - T config = get(type); - - if (config == null) { - throw new IllegalStateException("Config not created: " + type.getName()); - } - - return config; - } - - public void loadAll() { - configs.forEach(lifecycle::load); - } - - public void saveAll() { - configs.forEach(lifecycle::save); - } - - public @NotNull @Unmodifiable Set getConfigs() { - return Collections.unmodifiableSet(configs); - } - - public void clearAll() { - configs.clear(); - byType.clear(); - } - - private void register(Class type, ConfigSection config) { - configs.add(config); - byType.put(type, config); - } -} \ No newline at end of file diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigSection.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigSection.java index 8aa1d87..47038ce 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigSection.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigSection.java @@ -2,36 +2,11 @@ import eu.okaeri.configs.OkaeriConfig; import eu.okaeri.configs.serdes.OkaeriSerdesPack; -import org.jetbrains.annotations.NotNull; -/** - * Abstract base class for configuration sections. - * - *

- * Extends {@link OkaeriConfig} to provide a reusable foundation for plugin - * configuration sections. Subclasses are required to specify the - * serialization/deserialization pack and the configuration file name. - *

- * - *

- * Supports automatic recursive loading of nested {@link ConfigSection} - * subclasses declared as fields inside this class. - *

- */ public abstract class ConfigSection extends OkaeriConfig { - /** - * Returns the {@link OkaeriSerdesPack} instance used for serializing and deserializing - * this configuration section. - * - * @return non-null serialization/deserialization pack - */ - public abstract @NotNull OkaeriSerdesPack getSerdesPack(); + public abstract OkaeriSerdesPack serdesPack(); + + public abstract String fileName(); - /** - * Returns the filename (including extension) used to persist this configuration section. - * - * @return non-null configuration file name - */ - public abstract @NotNull String getFileName(); } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigService.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigService.java new file mode 100644 index 0000000..4dd928c --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigService.java @@ -0,0 +1,87 @@ +package com.github.imdmk.playtime.config; + +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.injector.subscriber.Subscribe; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeShutdownEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Unmodifiable; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.io.File; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Service(priority = ComponentPriority.LOWEST, order = 0) +public final class ConfigService { + + private final Set configs = ConcurrentHashMap.newKeySet(); + private final Map, ConfigSection> byType = new ConcurrentHashMap<>(); + + private final File dataFolder; + + private final ConfigFactory factory; + private final ConfigConfigurer configurer; + private final ConfigLifecycle lifecycle; + + @Inject + public ConfigService(@NotNull File dataFolder) { + this.dataFolder = dataFolder; + + this.factory = new ConfigFactory(); + this.configurer = new ConfigConfigurer(); + this.lifecycle = new ConfigLifecycle(); + } + + public C create(@NotNull Class type) { + final C config = factory.instantiate(type); + final File file = new File(dataFolder, config.fileName()); + + configurer.configure(config, file, config.serdesPack()); + lifecycle.initialize(config); + + register(type, config); + return config; + } + + @SuppressWarnings("unchecked") + public C get(@NotNull Class type) { + return (C) byType.get(type); + } + + public C require(@NotNull Class type) { + final C config = get(type); + if (config == null) { + throw new ConfigAccessException("Config not created: " + type.getName()); + } + + return config; + } + + public void loadAll() { + configs.forEach(lifecycle::load); + } + + public void saveAll() { + configs.forEach(lifecycle::save); + } + + @NotNull + @Unmodifiable + public Set getConfigs() { + return Collections.unmodifiableSet(configs); + } + + @Subscribe(event = PlayTimeShutdownEvent.class) + private void shutdown() { + configs.clear(); + byType.clear(); + } + + private void register(Class type, ConfigSection config) { + configs.add(config); + byType.put(type, config); + } +} \ No newline at end of file diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/InjectorConfigBinder.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/InjectorConfigBinder.java deleted file mode 100644 index c1ce395..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/config/InjectorConfigBinder.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.github.imdmk.playtime.config; - -import eu.okaeri.configs.OkaeriConfig; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.Resources; - -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; -import java.util.Collections; -import java.util.IdentityHashMap; -import java.util.Set; - -public final class InjectorConfigBinder { - - private InjectorConfigBinder() { - throw new UnsupportedOperationException("This is utility class and cannot be instantiated."); - } - - public static void bind(@NotNull Resources resources, @NotNull Set sections) { - final Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); - for (final var section : sections) { - bindRecursive(resources, section, visited); - } - } - - private static void bindRecursive(@NotNull Resources resources, @NotNull Object object, @NotNull Set visited) { - if (!visited.add(object)) { - return; - } - - resources.on(object.getClass()).assignInstance(object); - - for (Class clazz = object.getClass(); clazz != Object.class; clazz = clazz.getSuperclass()) { - for (Field field : clazz.getDeclaredFields()) { - int modifiers = field.getModifiers(); - if (Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers)) { - continue; - } - - try { - field.setAccessible(true); - Object value = field.get(object); - if (value instanceof OkaeriConfig nested) { - bindRecursive(resources, nested, visited); - } - } catch (IllegalAccessException e) { - throw new IllegalStateException("Failed to bind config field: " - + clazz.getSimpleName() + "#" + field.getName(), e); - } - } - } - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/PluginConfig.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/PluginConfig.java deleted file mode 100644 index b135f37..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/config/PluginConfig.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.imdmk.playtime.config; - -import com.github.imdmk.playtime.shared.time.DurationFormatStyle; -import eu.okaeri.configs.annotation.Comment; -import eu.okaeri.configs.serdes.OkaeriSerdesPack; -import org.jetbrains.annotations.NotNull; - -public final class PluginConfig extends ConfigSection { - - @Comment({ - "#", - "# Determines how durations (playtime, cooldowns, timers) are formatted", - "#", - "# Available options:", - "# - COMPACT: short form like 3d 4h 10m", - "# - LONG: full names, e.g. 3 days 4 hours", - "# - LONG_WITH_AND: natural flow, e.g. 3 days and 4 hours", - "# - NATURAL: comma-separated, e.g. 3 days, 4 hours", - "#" - }) - public DurationFormatStyle durationFormatStyle = DurationFormatStyle.LONG_WITH_AND; - - @Override - public @NotNull OkaeriSerdesPack getSerdesPack() { - return registry -> {}; - } - - @Override - public @NotNull String getFileName() { - return "pluginConfig.yml"; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/YamlConfigurerFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/YamlFactory.java similarity index 69% rename from playtime-core/src/main/java/com/github/imdmk/playtime/config/YamlConfigurerFactory.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/config/YamlFactory.java index a00409a..43446f9 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/config/YamlConfigurerFactory.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/config/YamlFactory.java @@ -1,6 +1,5 @@ package com.github.imdmk.playtime.config; -import eu.okaeri.configs.yaml.snakeyaml.YamlSnakeYamlConfigurer; import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; @@ -8,29 +7,27 @@ import org.yaml.snakeyaml.representer.Representer; import org.yaml.snakeyaml.resolver.Resolver; -final class YamlConfigurerFactory { +final class YamlFactory { - private YamlConfigurerFactory() { + private YamlFactory() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - static YamlSnakeYamlConfigurer create() { + static Yaml create() { final LoaderOptions loader = new LoaderOptions(); loader.setAllowRecursiveKeys(false); loader.setMaxAliasesForCollections(50); final Constructor constructor = new Constructor(loader); - final DumperOptions options = new DumperOptions(); options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); options.setIndent(2); options.setSplitLines(false); - final Representer representer = new ConfigRepresenter(options); + final Representer representer = new YamlRepresenter(options); final Resolver resolver = new Resolver(); - final Yaml yaml = new Yaml(constructor, representer, options, loader, resolver); - return new YamlSnakeYamlConfigurer(yaml); + return new Yaml(constructor, representer, options, loader, resolver); } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigRepresenter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/config/YamlRepresenter.java similarity index 95% rename from playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigRepresenter.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/config/YamlRepresenter.java index 0dd3c67..0e5e41c 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/config/ConfigRepresenter.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/config/YamlRepresenter.java @@ -11,9 +11,9 @@ import java.util.LinkedHashMap; import java.util.Map; -final class ConfigRepresenter extends Representer { +final class YamlRepresenter extends Representer { - ConfigRepresenter(DumperOptions options) { + YamlRepresenter(DumperOptions options) { super(options); this.representers.put(String.class, new RepresentString()); this.representers.put(Boolean.class, new RepresentBoolean()); diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/DataSourceConnector.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/DataSourceConnector.java new file mode 100644 index 0000000..6d4dab0 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/DataSourceConnector.java @@ -0,0 +1,74 @@ +package com.github.imdmk.playtime.database; + +import com.github.imdmk.playtime.database.configurer.DataSourceConfigurer; +import com.github.imdmk.playtime.platform.logger.PluginLogger; +import com.j256.ormlite.jdbc.DataSourceConnectionSource; +import com.j256.ormlite.support.ConnectionSource; +import com.zaxxer.hikari.HikariDataSource; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.sql.SQLException; + +final class DataSourceConnector { + + private final PluginLogger logger; + + private final DataSourceFactory dataSourceFactory; + private final DataSourceConfigurer dataSourceConfigurer; + + private volatile HikariDataSource dataSource; + private volatile ConnectionSource connectionSource; + + DataSourceConnector( + @NotNull PluginLogger logger, + @NotNull DataSourceFactory dataSourceFactory, + @NotNull DataSourceConfigurer dataSourceConfigurer + ) { + this.logger = logger; + this.dataSourceFactory = dataSourceFactory; + this.dataSourceConfigurer = dataSourceConfigurer; + } + + synchronized void connect(@NotNull DatabaseConfig config, @NotNull File dataFolder) throws SQLException { + if (dataSource != null || connectionSource != null) { + throw new IllegalStateException("DataSource is already connected"); + } + + final HikariDataSource dataSource = dataSourceFactory.create(config); + + dataSourceConfigurer.configure(dataSource, config, dataFolder); + if (dataSource.getJdbcUrl() == null) { + throw new IllegalStateException("JDBC URL was not set by DataSourceConfigurer"); + } + + final ConnectionSource connectionSource = new DataSourceConnectionSource(dataSource, dataSource.getJdbcUrl()); + + this.dataSource = dataSource; + this.connectionSource = connectionSource; + + logger.info("Connected to %s database.", config.databaseMode); + } + + synchronized void close() { + if (connectionSource != null) { + try { + connectionSource.close(); + } catch (Exception ignored) {} + } + + if (dataSource != null) { + dataSource.close(); + } + + connectionSource = null; + dataSource = null; + } + + @Nullable + ConnectionSource getConnectionSource() { + return connectionSource; + } +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/DataSourceFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/DataSourceFactory.java new file mode 100644 index 0000000..23b3525 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/DataSourceFactory.java @@ -0,0 +1,44 @@ +package com.github.imdmk.playtime.database; + +import com.zaxxer.hikari.HikariDataSource; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +final class DataSourceFactory { + + static final String POOL_NAME = "playtime-db-pool"; + + static final int MAX_POOL_SIZE = Math.max(4, Runtime.getRuntime().availableProcessors()); + static final int MIN_IDLE = 0; + + static final int CONNECTION_TIMEOUT = 10_000; + static final int IDLE_TIMEOUT = 60_000; + static final int MAX_LIFETIME = 600_000; + + static final Map SOURCE_PROPERTIES = Map.of( + "cachePrepStmts", true, + "prepStmtCacheSize", 250, + "prepStmtCacheSqlLimit", 2048, + "useServerPrepStmts", true + ); + + HikariDataSource create(@NotNull DatabaseConfig config) { + final HikariDataSource dataSource = new HikariDataSource(); + dataSource.setPoolName(POOL_NAME); + + dataSource.setUsername(config.databaseUserName); + dataSource.setPassword(config.databasePassword); + + dataSource.setMaximumPoolSize(MAX_POOL_SIZE); + dataSource.setMinimumIdle(MIN_IDLE); + + dataSource.setConnectionTimeout(CONNECTION_TIMEOUT); + dataSource.setIdleTimeout(IDLE_TIMEOUT); + dataSource.setMaxLifetime(MAX_LIFETIME); + + SOURCE_PROPERTIES.forEach(dataSource::addDataSourceProperty); + return dataSource; + } +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/DatabaseBootstrap.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/DatabaseBootstrap.java new file mode 100644 index 0000000..6ca7d86 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/DatabaseBootstrap.java @@ -0,0 +1,64 @@ +package com.github.imdmk.playtime.database; + +import com.github.imdmk.playtime.database.configurer.DataSourceConfigurer; +import com.github.imdmk.playtime.database.configurer.DataSourceConfigurerFactory; +import com.github.imdmk.playtime.database.library.DriverLibraryLoader; +import com.github.imdmk.playtime.injector.annotations.Database; +import com.github.imdmk.playtime.injector.subscriber.Subscribe; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeShutdownEvent; +import com.github.imdmk.playtime.platform.logger.PluginLogger; +import com.j256.ormlite.support.ConnectionSource; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.io.File; +import java.sql.SQLException; + +@Database +public final class DatabaseBootstrap { + + private final File dataFolder; + private final DatabaseConfig config; + + private final DriverLibraryLoader libraryLoader; + private final DataSourceConnector dataConnector; + + @Inject + public DatabaseBootstrap( + @NotNull Plugin plugin, + @NotNull File dataFolder, + @NotNull PluginLogger logger, + @NotNull DatabaseConfig config + ) { + this.dataFolder = dataFolder; + this.config = config; + + this.libraryLoader = new DriverLibraryLoader(plugin); + + final DataSourceConfigurer configurer = DataSourceConfigurerFactory.getFor(config.databaseMode); + final DataSourceFactory factory = new DataSourceFactory(); + this.dataConnector = new DataSourceConnector(logger, factory, configurer); + } + + public void start() { + libraryLoader.loadFor(config.databaseMode); + + try { + dataConnector.connect(config, dataFolder); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Nullable + public ConnectionSource getConnection() { + return dataConnector.getConnectionSource(); + } + + @Subscribe(event = PlayTimeShutdownEvent.class) + private void shutdown() { + dataConnector.close(); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseConfig.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/DatabaseConfig.java similarity index 81% rename from playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseConfig.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/database/DatabaseConfig.java index 58c6d18..6b22e19 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseConfig.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/DatabaseConfig.java @@ -1,18 +1,12 @@ -package com.github.imdmk.playtime.infrastructure.database; +package com.github.imdmk.playtime.database; import com.github.imdmk.playtime.config.ConfigSection; +import com.github.imdmk.playtime.injector.annotations.ConfigFile; import eu.okaeri.configs.annotation.Comment; import eu.okaeri.configs.serdes.OkaeriSerdesPack; import org.jetbrains.annotations.NotNull; -/** - * Configuration for the database connection layer. - *

- * Supports both embedded (SQLite/H2) and server-based engines (MySQL, MariaDB, PostgreSQL, SQL Server). - * Depending on {@link DatabaseMode}, only a subset of fields is used. - *

- * All unused fields for a given mode are safely ignored by the connector. - */ +@ConfigFile public final class DatabaseConfig extends ConfigSection { @Comment({ @@ -80,12 +74,12 @@ public final class DatabaseConfig extends ConfigSection { public int port = 3306; @Override - public @NotNull OkaeriSerdesPack getSerdesPack() { + public @NotNull OkaeriSerdesPack serdesPack() { return registry -> {}; } @Override - public @NotNull String getFileName() { - return "databaseConfig.yml"; + public @NotNull String fileName() { + return "database.yaml"; } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/DatabaseMode.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/DatabaseMode.java new file mode 100644 index 0000000..8a3f01c --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/DatabaseMode.java @@ -0,0 +1,5 @@ +package com.github.imdmk.playtime.database; + +public enum DatabaseMode { + MYSQL, MARIADB, SQLITE, POSTGRESQL, H2, SQL +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/DataSourceConfigurer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/DataSourceConfigurer.java new file mode 100644 index 0000000..3fdb6f8 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/DataSourceConfigurer.java @@ -0,0 +1,15 @@ +package com.github.imdmk.playtime.database.configurer; + +import com.github.imdmk.playtime.database.DatabaseConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.jetbrains.annotations.NotNull; + +import java.io.File; + +@FunctionalInterface +public interface DataSourceConfigurer { + + void configure(@NotNull HikariDataSource dataSource, + @NotNull DatabaseConfig config, + @NotNull File dataFolder); +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/DataSourceConfigurerFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/DataSourceConfigurerFactory.java new file mode 100644 index 0000000..fee9bf9 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/DataSourceConfigurerFactory.java @@ -0,0 +1,31 @@ +package com.github.imdmk.playtime.database.configurer; + +import com.github.imdmk.playtime.database.DatabaseMode; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +public final class DataSourceConfigurerFactory { + + private static final Map CONFIGURER_BY_MODE = Map.of( + DatabaseMode.MYSQL, new MySQLConfigurer(), + DatabaseMode.MARIADB, new MariaDBConfigurer(), + DatabaseMode.POSTGRESQL, new PostgreSQLConfigurer(), + DatabaseMode.SQLITE, new SQLiteConfigurer(), + DatabaseMode.H2, new H2Configurer(), + DatabaseMode.SQL, new SQLConfigurer() + ); + + public static DataSourceConfigurer getFor(@NotNull DatabaseMode mode) { + final DataSourceConfigurer configurer = CONFIGURER_BY_MODE.get(mode); + if (configurer == null) { + throw new IllegalArgumentException("Unsupported database mode: " + mode); + } + + return configurer; + } + + private DataSourceConfigurerFactory() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/H2Configurer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/H2Configurer.java similarity index 81% rename from playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/H2Configurer.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/H2Configurer.java index 05d37a0..9b94715 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/H2Configurer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/H2Configurer.java @@ -1,6 +1,6 @@ -package com.github.imdmk.playtime.infrastructure.database.driver.configurer; +package com.github.imdmk.playtime.database.configurer; -import com.github.imdmk.playtime.infrastructure.database.DatabaseConfig; +import com.github.imdmk.playtime.database.DatabaseConfig; import com.zaxxer.hikari.HikariDataSource; import org.jetbrains.annotations.NotNull; @@ -9,7 +9,7 @@ import java.nio.file.Files; import java.nio.file.Path; -final class H2Configurer implements DriverConfigurer { +final class H2Configurer implements DataSourceConfigurer { private static final String JDBC_URL = "jdbc:h2:file:%s" + ";MODE=MySQL" diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/MariaDBConfigurer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/MariaDBConfigurer.java similarity index 75% rename from playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/MariaDBConfigurer.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/MariaDBConfigurer.java index 2e0d70f..283af33 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/MariaDBConfigurer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/MariaDBConfigurer.java @@ -1,12 +1,12 @@ -package com.github.imdmk.playtime.infrastructure.database.driver.configurer; +package com.github.imdmk.playtime.database.configurer; -import com.github.imdmk.playtime.infrastructure.database.DatabaseConfig; +import com.github.imdmk.playtime.database.DatabaseConfig; import com.zaxxer.hikari.HikariDataSource; import org.jetbrains.annotations.NotNull; import java.io.File; -final class MariaDBConfigurer implements DriverConfigurer { +final class MariaDBConfigurer implements DataSourceConfigurer { private static final String JDBC_URL = "jdbc:mariadb://%s:%s/%s" + "?useUnicode=true" diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/MySQLConfigurer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/MySQLConfigurer.java similarity index 79% rename from playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/MySQLConfigurer.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/MySQLConfigurer.java index 1a81ce9..aceb877 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/MySQLConfigurer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/MySQLConfigurer.java @@ -1,12 +1,12 @@ -package com.github.imdmk.playtime.infrastructure.database.driver.configurer; +package com.github.imdmk.playtime.database.configurer; -import com.github.imdmk.playtime.infrastructure.database.DatabaseConfig; +import com.github.imdmk.playtime.database.DatabaseConfig; import com.zaxxer.hikari.HikariDataSource; import org.jetbrains.annotations.NotNull; import java.io.File; -final class MySQLConfigurer implements DriverConfigurer { +final class MySQLConfigurer implements DataSourceConfigurer { private static final String JDBC_URL = "jdbc:mysql://%s:%s/%s" + "?useSSL=false" diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/PostgreSQLConfigurer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/PostgreSQLConfigurer.java similarity index 73% rename from playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/PostgreSQLConfigurer.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/PostgreSQLConfigurer.java index 1dd14f0..68e75c7 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/PostgreSQLConfigurer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/PostgreSQLConfigurer.java @@ -1,12 +1,12 @@ -package com.github.imdmk.playtime.infrastructure.database.driver.configurer; +package com.github.imdmk.playtime.database.configurer; -import com.github.imdmk.playtime.infrastructure.database.DatabaseConfig; +import com.github.imdmk.playtime.database.DatabaseConfig; import com.zaxxer.hikari.HikariDataSource; import org.jetbrains.annotations.NotNull; import java.io.File; -final class PostgreSQLConfigurer implements DriverConfigurer { +final class PostgreSQLConfigurer implements DataSourceConfigurer { private static final String JDBC_URL = "jdbc:postgresql://%s:%s/%s" + "?sslmode=disable" diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/SQLConfigurer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/SQLConfigurer.java similarity index 74% rename from playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/SQLConfigurer.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/SQLConfigurer.java index 82d4fe4..c41fb9b 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/SQLConfigurer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/SQLConfigurer.java @@ -1,12 +1,12 @@ -package com.github.imdmk.playtime.infrastructure.database.driver.configurer; +package com.github.imdmk.playtime.database.configurer; -import com.github.imdmk.playtime.infrastructure.database.DatabaseConfig; +import com.github.imdmk.playtime.database.DatabaseConfig; import com.zaxxer.hikari.HikariDataSource; import org.jetbrains.annotations.NotNull; import java.io.File; -final class SQLConfigurer implements DriverConfigurer { +final class SQLConfigurer implements DataSourceConfigurer { private static final String JDBC_URL = "jdbc:sqlserver://%s:%s" + ";databaseName=%s" diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/SQLiteConfigurer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/SQLiteConfigurer.java similarity index 82% rename from playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/SQLiteConfigurer.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/SQLiteConfigurer.java index 5f344c1..8fe70cf 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/SQLiteConfigurer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/configurer/SQLiteConfigurer.java @@ -1,6 +1,6 @@ -package com.github.imdmk.playtime.infrastructure.database.driver.configurer; +package com.github.imdmk.playtime.database.configurer; -import com.github.imdmk.playtime.infrastructure.database.DatabaseConfig; +import com.github.imdmk.playtime.database.DatabaseConfig; import com.zaxxer.hikari.HikariDataSource; import org.jetbrains.annotations.NotNull; @@ -9,7 +9,7 @@ import java.nio.file.Files; import java.nio.file.Path; -final class SQLiteConfigurer implements DriverConfigurer { +final class SQLiteConfigurer implements DataSourceConfigurer { private static final String JDBC_URL = "jdbc:sqlite:%s"; diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/dependency/DriverLibraries.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/library/DriverLibraries.java similarity index 94% rename from playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/dependency/DriverLibraries.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/database/library/DriverLibraries.java index b48a4f6..aae238c 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/dependency/DriverLibraries.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/library/DriverLibraries.java @@ -1,4 +1,4 @@ -package com.github.imdmk.playtime.infrastructure.database.driver.dependency; +package com.github.imdmk.playtime.database.library; import com.alessiodp.libby.Library; diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/library/DriverLibraryLoader.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/library/DriverLibraryLoader.java new file mode 100644 index 0000000..fb8dd0c --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/library/DriverLibraryLoader.java @@ -0,0 +1,37 @@ +package com.github.imdmk.playtime.database.library; + +import com.alessiodp.libby.BukkitLibraryManager; +import com.alessiodp.libby.Library; +import com.github.imdmk.playtime.database.DatabaseMode; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +public final class DriverLibraryLoader { + + private static final Map LIBRARIES_BY_MODE = Map.of( + DatabaseMode.MYSQL, DriverLibraries.MYSQL, + DatabaseMode.MARIADB, DriverLibraries.MARIADB, + DatabaseMode.SQLITE, DriverLibraries.SQLITE, + DatabaseMode.POSTGRESQL, DriverLibraries.POSTGRESQL, + DatabaseMode.H2, DriverLibraries.H2, + DatabaseMode.SQL, DriverLibraries.SQL + ); + + private final BukkitLibraryManager libraryManager; + + public DriverLibraryLoader(@NotNull Plugin plugin) { + this.libraryManager = new BukkitLibraryManager(plugin); + this.libraryManager.addMavenCentral(); + } + + public void loadFor(@NotNull DatabaseMode mode) { + final Library library = LIBRARIES_BY_MODE.get(mode); + if (library == null) { + throw new IllegalArgumentException("Unsupported database mode: " + mode); + } + + libraryManager.loadLibrary(library); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/RepositoryBootstrap.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/RepositoryBootstrap.java new file mode 100644 index 0000000..81924ed --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/RepositoryBootstrap.java @@ -0,0 +1,9 @@ +package com.github.imdmk.playtime.database.repository; + +public interface RepositoryBootstrap extends AutoCloseable { + + void start() throws RepositoryInitializationException; + + @Override + void close(); +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/RepositoryInitializationException.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/RepositoryInitializationException.java new file mode 100644 index 0000000..3cca89e --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/RepositoryInitializationException.java @@ -0,0 +1,10 @@ +package com.github.imdmk.playtime.database.repository; + +public final class RepositoryInitializationException extends RuntimeException { + + public RepositoryInitializationException(Class repositoryClass, Throwable cause) { + super("Failed to initialize repository: " + repositoryClass.getName(), cause); + } + +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/ormlite/EntityMapper.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/ormlite/EntityMapper.java new file mode 100644 index 0000000..eacea6f --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/ormlite/EntityMapper.java @@ -0,0 +1,20 @@ +package com.github.imdmk.playtime.database.repository.ormlite; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public interface EntityMapper { + + E toEntity(@NotNull D domain); + + D toDomain(@NotNull E entity); + + default List toDomainList(@NotNull List entities) { + return entities.stream().map(this::toDomain).toList(); + } + + default List toEntityList(@NotNull List domains) { + return domains.stream().map(this::toEntity).toList(); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/ormlite/EntityMeta.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/ormlite/EntityMeta.java new file mode 100644 index 0000000..a9dce48 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/ormlite/EntityMeta.java @@ -0,0 +1,3 @@ +package com.github.imdmk.playtime.database.repository.ormlite; + +public interface EntityMeta {} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/ormlite/OrmLiteRepository.java b/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/ormlite/OrmLiteRepository.java new file mode 100644 index 0000000..05b81b2 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/database/repository/ormlite/OrmLiteRepository.java @@ -0,0 +1,109 @@ +package com.github.imdmk.playtime.database.repository.ormlite; + +import com.github.imdmk.playtime.database.DatabaseBootstrap; +import com.github.imdmk.playtime.database.repository.RepositoryBootstrap; +import com.github.imdmk.playtime.database.repository.RepositoryInitializationException; +import com.github.imdmk.playtime.platform.logger.PluginLogger; +import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; +import com.j256.ormlite.dao.Dao; +import com.j256.ormlite.dao.DaoManager; +import com.j256.ormlite.logger.Level; +import com.j256.ormlite.logger.Logger; +import com.j256.ormlite.support.ConnectionSource; +import com.j256.ormlite.table.TableUtils; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.sql.SQLException; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +public abstract class OrmLiteRepository + implements RepositoryBootstrap { + + private static final Duration EXECUTE_TIMEOUT = Duration.ofSeconds(3); + + protected final PluginLogger logger; + protected final TaskScheduler scheduler; + protected volatile Dao dao; + + private final DatabaseBootstrap databaseBootstrap; + + @Inject + protected OrmLiteRepository( + @NotNull PluginLogger logger, + @NotNull TaskScheduler scheduler, + @NotNull DatabaseBootstrap databaseBootstrap + ) { + this.logger = logger; + this.scheduler = scheduler; + this.databaseBootstrap = databaseBootstrap; + configureOrmLiteLogger(); + } + + protected abstract Class entityClass(); + + protected List> entitySubClasses() { + return List.of(); + } + + @Override + public void start() throws RepositoryInitializationException { + final ConnectionSource connection = databaseBootstrap.getConnection(); + if (connection == null) { + throw new IllegalStateException("DatabaseBootstrap not started before repository initialization"); + } + + for (final Class subClass : entitySubClasses()) { + try { + TableUtils.createTableIfNotExists(connection, subClass); + } catch (SQLException e) { + throw new RepositoryInitializationException(subClass, e); + } + } + + try { + TableUtils.createTableIfNotExists(connection, entityClass()); + dao = DaoManager.createDao(connection, entityClass()); + } catch (SQLException e) { + throw new RepositoryInitializationException(entityClass(), e); + } + } + + @Override + public void close() { + final Dao current = dao; + if (current == null) { + return; + } + + dao = null; + final ConnectionSource connection = current.getConnectionSource(); + if (connection != null) { + DaoManager.unregisterDao(connection, current); + } + } + + protected CompletableFuture execute(@NotNull Supplier supplier) { + if (dao == null) { + throw new IllegalStateException("Repository not initialized or already closed"); + } + + final CompletableFuture future = new CompletableFuture<>(); + scheduler.runAsync(() -> { + try { + future.complete(supplier.get()); + } catch (Exception e) { + future.completeExceptionally(e); + } + }); + return future.orTimeout(EXECUTE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } + + private static void configureOrmLiteLogger() { + Logger.setGlobalLogLevel(Level.ERROR); // only errors + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/MigrationConfig.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/MigrationConfig.java deleted file mode 100644 index 7386c46..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/MigrationConfig.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.github.imdmk.playtime.feature.migration; - -import com.github.imdmk.playtime.config.ConfigSection; -import eu.okaeri.configs.annotation.Comment; -import eu.okaeri.configs.serdes.OkaeriSerdesPack; -import org.jetbrains.annotations.NotNull; - -import java.time.Duration; - -public final class MigrationConfig extends ConfigSection { - - @Comment({ - "#", - "# Enables a one-time automatic migration on the first plugin startup.", - "#", - "# How it works:", - "# - When set to true, the plugin will perform a full server migration on startup.", - "# - After the migration completes successfully, the plugin will automatically set this value to false", - "# to prevent running the same migration again on the next startup.", - "# - Set this back to true manually only if you know what you are doing and want to re-run the initial migration.", - "#" - }) - public boolean initialServerMigrationEnabled = true; - - @Comment({ - "#", - "# Maximum allowed execution time for a single migration task.", - "#", - "# If a specific migration step (e.g. processing a batch of players) exceeds this duration,", - "# it will be treated as timed-out and can be cancelled or failed.", - "#" - }) - public Duration migrationTaskTimeout = Duration.ofSeconds(5); - - @Comment({ - "#", - "# Global timeout for the entire migration process.", - "#", - "# This is a hard upper limit for all migration tasks combined. If the full migration does not finish", - "# within this time window, the process will be considered failed or aborted.", - "#" - }) - public Duration migrationGlobalTimeout = Duration.ofMinutes(2); - - @Comment({ - "#", - "# Keep-alive interval for long-running migrations.", - "#", - "# Used to periodically signal that the migration is still active and progressing,", - "# preventing it from being treated as stalled when processing large datasets.", - "#" - }) - public Duration migrationKeepAliveInterval = Duration.ofMinutes(1); - - @Comment({ - "#", - "# Maximum number of migration tasks that can run concurrently.", - "#", - "# This controls how many player/segment migrations can be processed in parallel.", - "#", - "# Recommendations:", - "# - Low values (1–2) are safer for small or heavily loaded servers.", - "# - Higher values (4+) speed up migration but may increase CPU/IO usage.", - "#" - }) - public int migrationMaxConcurrency = 3; - - @Override - public @NotNull OkaeriSerdesPack getSerdesPack() { - return registry -> { - // No custom serializers required for this config. - }; - } - - @Override - public @NotNull String getFileName() { - return "migrationConfig.yml"; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/MigrationModule.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/MigrationModule.java deleted file mode 100644 index 9e02c03..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/MigrationModule.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.github.imdmk.playtime.feature.migration; - -import com.github.imdmk.playtime.feature.migration.migrator.PlayerMigrator; -import com.github.imdmk.playtime.feature.migration.migrator.RepositoryPlayerMigrator; -import com.github.imdmk.playtime.feature.migration.provider.BukkitPlayerProvider; -import com.github.imdmk.playtime.feature.migration.provider.PlayerProvider; -import com.github.imdmk.playtime.feature.migration.runner.BlockingMigrationRunner; -import com.github.imdmk.playtime.infrastructure.module.Module; -import org.bukkit.Server; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.Injector; -import org.panda_lang.utilities.inject.Resources; - -public final class MigrationModule implements Module { - - private PlayerMigrator migrator; - private PlayerProvider provider; - - @Override - public void bind(@NotNull Resources resources) { - resources.on(PlayerMigrator.class).assignInstance(() -> migrator); - resources.on(PlayerProvider.class).assignInstance(() -> provider); - } - - @Override - public void init(@NotNull Injector injector) {} - - @Override - public void afterRegister(@NotNull Plugin plugin, @NotNull Server server, @NotNull Injector injector) { - this.migrator = injector.newInstance(RepositoryPlayerMigrator.class); - this.provider = injector.newInstance(BukkitPlayerProvider.class); - - final BlockingMigrationRunner migrationRunner = injector.newInstance(BlockingMigrationRunner.class); - migrationRunner.execute(); - } - - @Override - public int order() { - return 10; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/MigrationResult.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/MigrationResult.java deleted file mode 100644 index 9209cda..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/MigrationResult.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.imdmk.playtime.feature.migration; - -import java.time.Duration; - -/** - * Immutable summary of a completed migration run. - * - *

This record captures aggregated statistics such as:

- *
    - *
  • total players processed,
  • - *
  • successful migrations,
  • - *
  • failed migrations,
  • - *
  • total elapsed time.
  • - *
- * - *

Instances of this record are typically created by a - * {@code MigrationRunner} implementation after completing the migration workflow.

- * - * @param total total number of players considered - * @param successful number of successful migrations - * @param failed number of failed migrations - * @param took total duration of the migration process - */ -public record MigrationResult(int total, int successful, int failed, Duration took) { - - /** - * Returns an empty result representing a migration that processed - * no players and took zero time. - * - * @return a zero-valued {@code MigrationResult} - */ - public static MigrationResult empty() { - return new MigrationResult(0, 0, 0, Duration.ZERO); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/listener/ConfigMigrationListener.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/listener/ConfigMigrationListener.java deleted file mode 100644 index 2285ee5..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/listener/ConfigMigrationListener.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.imdmk.playtime.feature.migration.listener; - -import com.github.imdmk.playtime.feature.migration.MigrationConfig; -import com.github.imdmk.playtime.feature.migration.MigrationResult; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.jetbrains.annotations.NotNull; - -public final class ConfigMigrationListener implements MigrationListener { - - private final MigrationConfig config; - - public ConfigMigrationListener(@NotNull MigrationConfig config) { - this.config = Validator.notNull(config, "config cannot be null"); - } - - @Override - public void onEnd(@NotNull MigrationResult result) { - config.initialServerMigrationEnabled = false; - config.save(); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/listener/LoggerMigrationListener.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/listener/LoggerMigrationListener.java deleted file mode 100644 index 6a7b97f..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/listener/LoggerMigrationListener.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.github.imdmk.playtime.feature.migration.listener; - -import com.github.imdmk.playtime.feature.migration.MigrationResult; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.bukkit.OfflinePlayer; -import org.jetbrains.annotations.NotNull; - -public final class LoggerMigrationListener implements MigrationListener { - - private final PluginLogger logger; - - private volatile int completed; - private volatile int total; - - public LoggerMigrationListener(@NotNull PluginLogger logger) { - this.logger = Validator.notNull(logger, "logger cannot be null"); - } - - @Override - public void onStart(int total) { - this.total = total; - this.completed = 0; - logger.info("Starting first-time migration of %d players...", total); - } - - @Override - public void onSuccess(@NotNull OfflinePlayer player) { - incrementAndLogProgress(); - } - - @Override - public void onFailed(@NotNull OfflinePlayer player, @NotNull Throwable throwable) { - logger.warn("Migration failed for %s: %s", player.getUniqueId(), throwable.getMessage()); - incrementAndLogProgress(); - } - - @Override - public void onEnd(@NotNull MigrationResult result) { - logger.info("Migration ended: success=%d, failed=%d, took=%sms", result.successful(), result.failed(), result.took().toMillis()); - } - - private void incrementAndLogProgress() { - int done = completed + 1; - int total = Math.max(1, this.total); - int percent = (int) ((done * 100L) / total); - - if (percent % 5 == 0 || done == total) { - logger.info("Migration progress: %d%% (%d/%d)", percent, done, total); - } - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/listener/MigrationListener.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/listener/MigrationListener.java deleted file mode 100644 index 6fcd36a..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/listener/MigrationListener.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.github.imdmk.playtime.feature.migration.listener; - -import com.github.imdmk.playtime.feature.migration.MigrationResult; -import org.bukkit.OfflinePlayer; -import org.jetbrains.annotations.NotNull; - -/** - * Listener interface for receiving callbacks during a playtime data migration process. - * - *

This listener allows external components to observe and react to the - * lifecycle of a migration operation – from start, through per-player - * results, to the final completion summary.

- * - *

All methods are optional; implementations may override only the events - * they are interested in.

- */ -public interface MigrationListener { - - /** - * Called when the migration process begins. - * - * @param total total number of players scheduled for migration - */ - default void onStart(int total) {} - - /** - * Called when a player's data has been migrated successfully. - * - * @param player the offline player whose migration completed successfully - */ - default void onSuccess(@NotNull OfflinePlayer player) {} - - /** - * Called when a player's migration fails due to an unexpected error. - * - * @param player the offline player whose migration failed - * @param throwable the exception that caused the failure - */ - default void onFailed(@NotNull OfflinePlayer player, @NotNull Throwable throwable) {} - - /** - * Called when the migration process has completed for all players. - * - * @param result summary of the migration, including counts of successes, failures, - * and total processed players - */ - default void onEnd(@NotNull MigrationResult result) {} -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/migrator/PlayerMigrator.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/migrator/PlayerMigrator.java deleted file mode 100644 index 7dbffd6..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/migrator/PlayerMigrator.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.github.imdmk.playtime.feature.migration.migrator; - -import com.github.imdmk.playtime.user.User; -import org.bukkit.OfflinePlayer; -import org.jetbrains.annotations.NotNull; - -import java.util.concurrent.CompletableFuture; - -/** - * Strategy interface responsible for migrating playtime data for a single - * {@link OfflinePlayer} into the plugin’s internal {@link User} domain model. - * - *

Implementations of this interface define how legacy or external data sources - * (e.g., Bukkit statistics API, flat files, third-party plugins, SQL tables) - * are translated into the unified User format used by the PlayTime system.

- * - *

Async contract:
- * The migration operation must be non-blocking and executed asynchronously. - * All heavy computation and I/O must run off the main server thread. - * The returned {@link CompletableFuture} represents the result of the migration.

- * - *

This interface is commonly used by bulk migration processes that iterate - * through all stored players and invoke this migrator per user.

- */ -@FunctionalInterface -public interface PlayerMigrator { - - /** - * Migrates playtime data for the given {@link OfflinePlayer}. - * - * @param player the offline player whose data should be migrated (never null) - * @return a future completing with the migrated {@link User} instance, - * or completing exceptionally if the migration fails - */ - CompletableFuture migrate(@NotNull OfflinePlayer player); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/migrator/RepositoryPlayerMigrator.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/migrator/RepositoryPlayerMigrator.java deleted file mode 100644 index fbaaba5..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/migrator/RepositoryPlayerMigrator.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.imdmk.playtime.feature.migration.migrator; - -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserFactory; -import com.github.imdmk.playtime.user.repository.UserRepository; -import org.bukkit.OfflinePlayer; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.util.concurrent.CompletableFuture; - -public final class RepositoryPlayerMigrator implements PlayerMigrator { - - private final UserRepository userRepository; - private final UserFactory userFactory; - - @Inject - public RepositoryPlayerMigrator( - @NotNull UserRepository userRepository, - @NotNull UserFactory userFactory - ) { - this.userRepository = Validator.notNull(userRepository, "userRepository cannot be null"); - this.userFactory = Validator.notNull(userFactory, "userFactory cannot be null"); - } - - @Override - public CompletableFuture migrate(@NotNull OfflinePlayer player) { - final User user = userFactory.createFrom(player); - return userRepository.save(user); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/provider/BukkitPlayerProvider.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/provider/BukkitPlayerProvider.java deleted file mode 100644 index 2381519..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/provider/BukkitPlayerProvider.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.imdmk.playtime.feature.migration.provider; - -import com.github.imdmk.playtime.shared.validate.Validator; -import org.bukkit.OfflinePlayer; -import org.bukkit.Server; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Unmodifiable; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.util.Arrays; -import java.util.Collection; -import java.util.List; - -public final class BukkitPlayerProvider implements PlayerProvider { - - private final Server server; - - @Inject - public BukkitPlayerProvider(@NotNull Server server) { - this.server = Validator.notNull(server, "server cannot be null"); - } - - @Override - public @NotNull @Unmodifiable Collection getAllPlayers() { - return List.copyOf(Arrays.asList(server.getOfflinePlayers())); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/provider/PlayerProvider.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/provider/PlayerProvider.java deleted file mode 100644 index 73826e8..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/provider/PlayerProvider.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.github.imdmk.playtime.feature.migration.provider; - -import org.bukkit.OfflinePlayer; -import org.jetbrains.annotations.NotNull; - -import java.util.Collection; - -/** - * Provides access to all players that should participate in a migration run. - * - *

Typical implementations include: - *

    - *
  • fetching all known {@link OfflinePlayer} instances from Bukkit,
  • - *
  • loading players from external storage,
  • - *
  • filtering players based on custom eligibility rules.
  • - *
- * - *

The contract guarantees that the returned collection is non-null, - * but may be empty.

- */ -public interface PlayerProvider { - - /** - * Returns a collection of all players eligible for migration. - * - * @return a non-null collection containing zero or more {@link OfflinePlayer} instances - */ - @NotNull Collection getAllPlayers(); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/AsyncMigrationRunner.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/AsyncMigrationRunner.java deleted file mode 100644 index f961edf..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/AsyncMigrationRunner.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.github.imdmk.playtime.feature.migration.runner; - -import com.github.imdmk.playtime.feature.migration.MigrationConfig; -import com.github.imdmk.playtime.feature.migration.MigrationResult; -import com.github.imdmk.playtime.feature.migration.listener.ConfigMigrationListener; -import com.github.imdmk.playtime.feature.migration.listener.LoggerMigrationListener; -import com.github.imdmk.playtime.feature.migration.listener.MigrationListener; -import com.github.imdmk.playtime.feature.migration.migrator.PlayerMigrator; -import com.github.imdmk.playtime.feature.migration.provider.PlayerProvider; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; -import org.panda_lang.utilities.inject.annotations.PostConstruct; - -import java.time.Duration; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -public final class AsyncMigrationRunner - implements MigrationRunner>, AutoCloseable { - - private static final String EXECUTOR_THREAD_NAME = "playtime-async-migration-"; - - private final PluginLogger logger; - private final MigrationConfig config; - private final PlayerProvider provider; - private final PlayerMigrator migrator; - - private ExecutorService executor; - - @Inject - public AsyncMigrationRunner( - @NotNull PluginLogger logger, - @NotNull MigrationConfig config, - @NotNull PlayerProvider provider, - @NotNull PlayerMigrator migrator - ) { - this.logger = Validator.notNull(logger, "logger cannot be null"); - this.config = Validator.notNull(config, "config cannot be null"); - this.provider = Validator.notNull(provider, "provider cannot be null"); - this.migrator = Validator.notNull(migrator, "migrator cannot be null"); - } - - @PostConstruct - void postConstruct() { - this.executor = createNewExecutor(config.migrationMaxConcurrency, config.migrationKeepAliveInterval); - } - - @Override - public CompletableFuture execute() { - var runner = new MigrationRunnerImpl(config, provider, migrator, listeners()); - return CompletableFuture.supplyAsync(runner::execute, executor); - } - - @Override - public List listeners() { - return List.of( - new ConfigMigrationListener(config), - new LoggerMigrationListener(logger) - ); - } - - @Override - public void close() { - executor.shutdown(); - try { - if (!executor.awaitTermination(15, TimeUnit.SECONDS)) { - executor.shutdownNow(); - } - } catch (InterruptedException e) { - executor.shutdownNow(); - Thread.currentThread().interrupt(); - } - } - - private ExecutorService createNewExecutor(int maxConcurrency, Duration keepAlive) { - return new ThreadPoolExecutor( - maxConcurrency, maxConcurrency, - keepAlive.toMillis(), TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>(), - newThreadFactory(), - new ThreadPoolExecutor.CallerRunsPolicy() - ); - } - - private ThreadFactory newThreadFactory() { - return new ThreadFactory() { - - private final ThreadFactory base = Executors.defaultThreadFactory(); - private final AtomicInteger seq = new AtomicInteger(1); - - @Override - public Thread newThread(@NotNull Runnable r) { - Thread thread = base.newThread(r); - thread.setName(EXECUTOR_THREAD_NAME + seq.getAndIncrement()); - thread.setDaemon(true); - return thread; - } - }; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/BlockingMigrationRunner.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/BlockingMigrationRunner.java deleted file mode 100644 index 5a2ae6c..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/BlockingMigrationRunner.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.github.imdmk.playtime.feature.migration.runner; - -import com.github.imdmk.playtime.feature.migration.MigrationConfig; -import com.github.imdmk.playtime.feature.migration.MigrationResult; -import com.github.imdmk.playtime.feature.migration.listener.ConfigMigrationListener; -import com.github.imdmk.playtime.feature.migration.listener.LoggerMigrationListener; -import com.github.imdmk.playtime.feature.migration.listener.MigrationListener; -import com.github.imdmk.playtime.feature.migration.migrator.PlayerMigrator; -import com.github.imdmk.playtime.feature.migration.provider.PlayerProvider; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.util.List; - -public final class BlockingMigrationRunner implements MigrationRunner { - - private final PluginLogger logger; - private final MigrationConfig config; - private final PlayerProvider provider; - private final PlayerMigrator migrator; - - @Inject - public BlockingMigrationRunner( - @NotNull PluginLogger logger, - @NotNull MigrationConfig config, - @NotNull PlayerProvider provider, - @NotNull PlayerMigrator migrator - ) { - this.logger = Validator.notNull(logger, "logger cannot be null"); - this.config = Validator.notNull(config, "config cannot be null"); - this.provider = Validator.notNull(provider, "provider cannot be null"); - this.migrator = Validator.notNull(migrator, "migrator cannot be null"); - } - - @Override - public MigrationResult execute() { - final var runner = new MigrationRunnerImpl(config, provider, migrator, listeners()); - return runner.execute(); - } - - @Override - public List listeners() { - return List.of( - new ConfigMigrationListener(config), - new LoggerMigrationListener(logger) - ); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/MigrationRunner.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/MigrationRunner.java deleted file mode 100644 index 6e2175d..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/MigrationRunner.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.github.imdmk.playtime.feature.migration.runner; - -import com.github.imdmk.playtime.feature.migration.listener.MigrationListener; - -import java.util.List; - -/** - * Executes a migration process according to a specific strategy and - * dispatches events to registered {@link MigrationListener} instances. - * - *

A {@code MigrationRunner} typically orchestrates:

- *
    - *
  • retrieving players from a {@code PlayerProvider},
  • - *
  • delegating work to a {@code PlayerMigrator},
  • - *
  • collecting results and computing statistics,
  • - *
  • notifying listeners about progress.
  • - *
- * - *

Implementations define whether execution is synchronous or asynchronous, - * and establish the threading model used for callbacks.

- * - * @param the type returned upon completion (e.g. {@code MigrationResult}) - */ -public interface MigrationRunner { - - /** - * Executes the migration process. - * - * @return a result object summarizing the completed migration - */ - T execute(); - - /** - * Returns all listeners that will receive migration callbacks. - * - * @return an immutable or defensive-copied list of listeners - */ - List listeners(); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/MigrationRunnerImpl.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/MigrationRunnerImpl.java deleted file mode 100644 index e463420..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/migration/runner/MigrationRunnerImpl.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.github.imdmk.playtime.feature.migration.runner; - -import com.github.imdmk.playtime.feature.migration.MigrationConfig; -import com.github.imdmk.playtime.feature.migration.MigrationResult; -import com.github.imdmk.playtime.feature.migration.listener.MigrationListener; -import com.github.imdmk.playtime.feature.migration.migrator.PlayerMigrator; -import com.github.imdmk.playtime.feature.migration.provider.PlayerProvider; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.google.common.base.Stopwatch; -import org.bukkit.OfflinePlayer; -import org.jetbrains.annotations.NotNull; - -import java.time.Duration; -import java.util.Collection; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; - -final class MigrationRunnerImpl { - - private final MigrationConfig config; - private final PlayerProvider provider; - private final PlayerMigrator migrator; - private final List listeners; - - MigrationRunnerImpl( - @NotNull MigrationConfig config, - @NotNull PlayerProvider provider, - @NotNull PlayerMigrator migrator, - @NotNull List listeners - ) { - this.config = Validator.notNull(config, "config cannot be null"); - this.provider = Validator.notNull(provider, "provider cannot be null"); - this.migrator = Validator.notNull(migrator, "migrator cannot be null"); - this.listeners = Validator.notNull(listeners, "listeners cannot be null"); - } - - MigrationResult execute() { - if (!config.initialServerMigrationEnabled) { - return MigrationResult.empty(); - } - - final Stopwatch stopwatch = Stopwatch.createStarted(); - - final Collection players = provider.getAllPlayers(); - final int total = players.size(); - - listenersForEach(l -> l.onStart(total)); - if (total == 0) { - final MigrationResult empty = MigrationResult.empty(); - listenersForEach(l -> l.onEnd(empty)); - return empty; - } - - final AtomicInteger success = new AtomicInteger(); - final AtomicInteger failed = new AtomicInteger(); - final AtomicInteger inflight = new AtomicInteger(total); - - final Semaphore limiter = new Semaphore(config.migrationMaxConcurrency); - final CompletableFuture allDone = new CompletableFuture<>(); - - for (final OfflinePlayer player : players) { - limiter.acquireUninterruptibly(); - - migrator.migrate(player) - .orTimeout(config.migrationTaskTimeout.toMillis(), TimeUnit.MILLISECONDS) - .whenComplete((u, e) -> { - try { - if (e == null) { - success.incrementAndGet(); - listenersForEach(l -> l.onSuccess(player)); - } else { - failed.incrementAndGet(); - listenersForEach(l -> l.onFailed(player, e)); - } - } finally { - limiter.release(); - if (inflight.decrementAndGet() == 0) { - allDone.complete(null); - } - } - }); - } - - allDone.orTimeout(config.migrationGlobalTimeout.toMillis(), TimeUnit.MILLISECONDS).join(); - - final Duration took = stopwatch.stop().elapsed(); - final MigrationResult result = new MigrationResult(total, success.get(), failed.get(), took); - - listenersForEach(l -> l.onEnd(result)); - - return result; - } - - private void listenersForEach(@NotNull Consumer listenerConsumer) { - for (final var listener : listeners) { - listenerConsumer.accept(listener); - } - } - -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/BukkitPlayTimeService.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/BukkitPlayTimeService.java deleted file mode 100644 index f9d48c2..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/BukkitPlayTimeService.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime; - -import com.github.imdmk.playtime.PlaytimeService; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.UserTime; -import org.bukkit.OfflinePlayer; -import org.bukkit.Server; -import org.bukkit.Statistic; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.util.UUID; - -/** - * Implementation of {@link PlaytimeService} that retrieves and modifies player playtime data - * directly from the Bukkit {@link Server} statistics API. - *

- * This service operates exclusively on the primary (main) thread of the Bukkit server. - * Any access attempt from a non-primary thread will result in an {@link UnsupportedOperationException}. - *

- * The playtime is based on {@link Statistic#PLAY_ONE_MINUTE}, which internally stores values - * in Minecraft ticks (20 ticks = 1 second). The {@link UserTime} abstraction is used to - * convert between ticks and higher-level time units. - *

- * Thread-safety note: Bukkit statistic access is not thread-safe. - * Always ensure that invocations are done synchronously on the main thread. - */ -final class BukkitPlayTimeService implements PlaytimeService { - - private static final Statistic PLAYTIME_STATISTIC = Statistic.PLAY_ONE_MINUTE; - private static final UserTime ZERO_TIME = UserTime.ZERO; - - private final Server server; - - @Inject - BukkitPlayTimeService(@NotNull Server server) { - this.server = Validator.notNull(server, "server"); - } - - /** - * Retrieves the total playtime of the specified player. - * - * @param uuid the UUID of the target player (must not be {@code null}) - * @return a {@link UserTime} instance representing the player’s total playtime, - * or {@link UserTime#ZERO} if the player has never joined or has zero ticks recorded - * @throws UnsupportedOperationException if called from a non-primary thread - * @throws NullPointerException if {@code uuid} is {@code null} - */ - @Override - public @NotNull UserTime getTime(@NotNull UUID uuid) { - Validator.notNull(uuid, "uuid cannot be null"); - - if (!isPrimaryThread()) { - throw new UnsupportedOperationException( - "BukkitPlaytimeService#getTime must be called from the primary thread." - ); - } - - int timeTicks = getOffline(uuid).getStatistic(PLAYTIME_STATISTIC); - if (timeTicks <= 0) { - return ZERO_TIME; - } - - return UserTime.ofTicks(timeTicks); - } - - /** - * Sets the total playtime of the specified player to the given value. - * - * @param uuid the UUID of the target player (must not be {@code null}) - * @param time the desired new total playtime (must not be {@code null}) - * @throws UnsupportedOperationException if called from a non-primary thread - * @throws NullPointerException if any argument is {@code null} - */ - @Override - public void setTime(@NotNull UUID uuid, @NotNull UserTime time) { - Validator.notNull(uuid, "uuid cannot be null"); - Validator.notNull(time, "time cannot be null"); - - if (!isPrimaryThread()) { - throw new UnsupportedOperationException( - "BukkitPlaytimeService#setTime must be called from the primary thread." - ); - } - - getOffline(uuid).setStatistic(PLAYTIME_STATISTIC, time.toTicks()); - } - - /** - * Resets the total playtime of the specified player to zero. - * - * @param uuid the UUID of the target player - * @throws UnsupportedOperationException if called from a non-primary thread - */ - @Override - public void resetTime(@NotNull UUID uuid) { - setTime(uuid, ZERO_TIME); - } - - /** - * Retrieves the {@link OfflinePlayer} instance associated with the given UUID. - * - * @param uuid the player's UUID (must not be {@code null}) - * @return the corresponding {@link OfflinePlayer} handle - */ - private @NotNull OfflinePlayer getOffline(@NotNull UUID uuid) { - return server.getOfflinePlayer(uuid); - } - - /** - * Checks whether the current execution is happening on the primary (main) Bukkit thread. - * - * @return {@code true} if running on the main thread, otherwise {@code false} - */ - private boolean isPrimaryThread() { - return server.isPrimaryThread(); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeApiAdapter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeApiAdapter.java new file mode 100644 index 0000000..205bd6d --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeApiAdapter.java @@ -0,0 +1,39 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.github.imdmk.playtime.PlayTime; +import com.github.imdmk.playtime.PlayTimeApi; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class PlayTimeApiAdapter implements PlayTimeApi { + + private final PlayTimeUserService userService; + + @Inject + PlayTimeApiAdapter(@NotNull PlayTimeUserService userService) { + this.userService = userService; + } + + @Override + public CompletableFuture getTime(@NotNull UUID uuid) { + return userService.getPlayTime(uuid); + } + + @Override + public CompletableFuture setTime(@NotNull UUID uuid, @NotNull PlayTime time) { + return userService.setPlayTime(uuid, time); + } + + @Override + public CompletableFuture addTime(@NotNull UUID uuid, @NotNull PlayTime delta) { + return userService.addPlayTime(uuid, delta); + } + + @Override + public CompletableFuture resetTime(@NotNull UUID uuid) { + return userService.setPlayTime(uuid, PlayTime.ZERO); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeCommand.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeCommand.java new file mode 100644 index 0000000..83597e7 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeCommand.java @@ -0,0 +1,86 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.github.imdmk.playtime.PlayTime; +import com.github.imdmk.playtime.injector.annotations.lite.LiteCommand; +import com.github.imdmk.playtime.platform.identity.IdentityService; +import com.github.imdmk.playtime.shared.message.MessageService; +import com.github.imdmk.playtime.shared.time.Durations; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Context; +import dev.rollczi.litecommands.annotations.execute.Execute; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.time.Duration; +import java.util.UUID; + +@LiteCommand +@Command(name = "playtime") +final class PlayTimeCommand { + + private final MessageService messageService; + private final IdentityService identityService; + private final PlayTimeUserService userService; + private final PlayTimeQueryService playTimeQueryService; + + @Inject + PlayTimeCommand( + @NotNull MessageService messageService, + @NotNull IdentityService identityService, + @NotNull PlayTimeUserService userService, + @NotNull PlayTimeQueryService playTimeQueryService + ) { + this.messageService = messageService; + this.identityService = identityService; + this.userService = userService; + this.playTimeQueryService = playTimeQueryService; + } + + @Execute + void playTime(@Context Player player) { + playTimeQueryService.getCurrentPlayTime(player.getUniqueId()) + .thenAccept(playTime -> messageService.create() + .viewer(player) + .notice(n -> n.playtimeMessages.playerPlayTimeSelf()) + .placeholder("{PLAYER_PLAYTIME}", Durations.format(playTime.toDuration())) + .send()) + .exceptionally(e -> { + messageService.send(player, notice -> notice.actionExecutionError); + return null; + }); + } + + @Execute + void playTimeOther(@Context CommandSender sender, @Arg UUID playerId) { + playTimeQueryService.getCurrentPlayTime(playerId) + .thenAccept(playTime -> messageService.create() + .viewer(sender) + .notice(n -> n.playtimeMessages.playerPlayTimeTarget()) + .placeholder("{PLAYER_NAME}", identityService.resolvePlayerName(playerId)) + .placeholder("{PLAYER_PLAYTIME}", Durations.format(playTime.toDuration())) + .send()) + .exceptionally(e -> { + messageService.send(sender, notice -> notice.actionExecutionError); + return null; + }); + } + + @Execute + void setPlayTime(@Context CommandSender sender, @Arg UUID playerId, @Arg Duration time) { + final PlayTime playTime = PlayTime.of(time); + + userService.setPlayTime(playerId, playTime) + .thenAccept(v -> messageService.create() + .viewer(sender) + .notice(n -> n.playtimeMessages.playerPlayTimeUpdated()) + .placeholder("{PLAYER_PLAYTIME}", Durations.format(time)) + .send()) + .exceptionally(e -> { + messageService.send(sender, notice -> notice.actionExecutionError); + return null; + }); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeModule.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeModule.java deleted file mode 100644 index bad056f..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeModule.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime; - -import com.github.imdmk.playtime.PlaytimeService; -import com.github.imdmk.playtime.feature.playtime.command.TimeCommand; -import com.github.imdmk.playtime.feature.playtime.command.TimeResetAllCommand; -import com.github.imdmk.playtime.feature.playtime.command.TimeResetCommand; -import com.github.imdmk.playtime.feature.playtime.command.TimeSetCommand; -import com.github.imdmk.playtime.feature.playtime.command.TimeTopCommand; -import com.github.imdmk.playtime.feature.playtime.command.TimeTopInvalidateCommand; -import com.github.imdmk.playtime.feature.playtime.gui.PlayTimeTopGui; -import com.github.imdmk.playtime.feature.playtime.listener.PlayTimeSaveListener; -import com.github.imdmk.playtime.feature.playtime.placeholder.PlayTimePlaceholder; -import com.github.imdmk.playtime.infrastructure.module.Module; -import com.github.imdmk.playtime.infrastructure.module.phase.CommandPhase; -import com.github.imdmk.playtime.infrastructure.module.phase.GuiPhase; -import com.github.imdmk.playtime.infrastructure.module.phase.ListenerPhase; -import com.github.imdmk.playtime.infrastructure.module.phase.PlaceholderPhase; -import com.github.imdmk.playtime.user.UserFactory; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.Injector; -import org.panda_lang.utilities.inject.Resources; - -public final class PlayTimeModule implements Module { - - private PlaytimeService playtimeService; - private UserFactory userFactory; - - @Override - public void bind(@NotNull Resources resources) { - resources.on(PlaytimeService.class).assignInstance(() -> this.playtimeService); - resources.on(UserFactory.class).assignInstance(() -> this.userFactory); - } - - @Override - public void init(@NotNull Injector injector) { - this.playtimeService = injector.newInstance(BukkitPlayTimeService.class); - this.userFactory = injector.newInstance(PlayTimeUserFactory.class); - } - - @Override - public CommandPhase commands(@NotNull Injector injector) { - return builder -> builder.commands( - injector.newInstance(TimeCommand.class), - injector.newInstance(TimeSetCommand.class), - injector.newInstance(TimeTopCommand.class), - injector.newInstance(TimeResetCommand.class), - injector.newInstance(TimeResetAllCommand.class), - injector.newInstance(TimeTopInvalidateCommand.class) - ); - } - - @Override - public ListenerPhase listeners(@NotNull Injector injector) { - return builder -> builder.register( - injector.newInstance(PlayTimeSaveListener.class) - ); - } - - @Override - public GuiPhase guis(@NotNull Injector injector) { - return guiRegistry -> guiRegistry.register(injector.newInstance(PlayTimeTopGui.class)); - } - - @Override - public PlaceholderPhase placeholders(@NotNull Injector injector) { - return adapter -> adapter.register( - injector.newInstance(PlayTimePlaceholder.class) - ); - } - - @Override - public int order() { - return -1; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimePlaceholder.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimePlaceholder.java new file mode 100644 index 0000000..dc76062 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimePlaceholder.java @@ -0,0 +1,36 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.github.imdmk.playtime.PlayTime; +import com.github.imdmk.playtime.injector.annotations.placeholderapi.Placeholder; +import com.github.imdmk.playtime.platform.placeholder.PluginPlaceholder; +import com.github.imdmk.playtime.shared.time.Durations; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +@Placeholder +final class PlayTimePlaceholder implements PluginPlaceholder { + + private static final String IDENTIFIER = "playtime"; + + private final PlayTimeUserCache cache; + + @Inject + PlayTimePlaceholder(@NotNull PlayTimeUserCache cache) { + this.cache = cache; + } + + @Override + public String identifier() { + return IDENTIFIER; + } + + @Override + public String request(@NotNull Player player, @NotNull String params) { + final PlayTime cachedPlayTime = cache.get(player.getUniqueId()) + .map(PlayTimeUser::getPlayTime) + .orElse(PlayTime.ZERO); + + return Durations.format(cachedPlayTime.toDuration()); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeQueryService.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeQueryService.java new file mode 100644 index 0000000..1f04035 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeQueryService.java @@ -0,0 +1,38 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.github.imdmk.playtime.PlayTime; +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.platform.playtime.PlayTimeAdapter; +import org.bukkit.Server; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +@Service(priority = ComponentPriority.HIGH) +final class PlayTimeQueryService { + + private final Server server; + private final PlayTimeAdapter playTimeAdapter; + private final PlayTimeUserService userService; + + @Inject + PlayTimeQueryService( + @NotNull Server server, + @NotNull PlayTimeAdapter playTimeAdapter, + @NotNull PlayTimeUserService userService + ) { + this.server = server; + this.playTimeAdapter = playTimeAdapter; + this.userService = userService; + } + + public CompletableFuture getCurrentPlayTime(@NotNull UUID uuid) { + return Optional.ofNullable(server.getPlayer(uuid)) + .map(online -> CompletableFuture.completedFuture(playTimeAdapter.read(online))) + .orElseGet(() -> userService.getPlayTime(uuid)); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeSaveListener.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeSaveListener.java new file mode 100644 index 0000000..b51ce49 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeSaveListener.java @@ -0,0 +1,62 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.github.imdmk.playtime.PlayTime; +import com.github.imdmk.playtime.injector.annotations.Controller; +import com.github.imdmk.playtime.platform.logger.PluginLogger; +import com.github.imdmk.playtime.platform.playtime.PlayTimeAdapter; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.util.UUID; + +@Controller +final class PlayTimeSaveListener implements Listener { + + private final PluginLogger logger; + private final PlayTimeAdapter playTimeAdapter; + private final PlayTimeUserService userService; + + @Inject + PlayTimeSaveListener( + @NotNull PluginLogger logger, + @NotNull PlayTimeAdapter playTimeAdapter, + @NotNull PlayTimeUserService userService + ) { + this.logger = logger; + this.playTimeAdapter = playTimeAdapter; + this.userService = userService; + } + + @EventHandler + void onPlayerJoin(PlayerJoinEvent event) { + final Player player = event.getPlayer(); + final UUID uuid = player.getUniqueId(); + final PlayTime playTime = playTimeAdapter.read(player); + + userService.getOrCreate(uuid, playTime) + .thenAccept(user -> playTimeAdapter.write(player, user.getPlayTime())) + .exceptionally(e -> { + logger.error(e, "Failed to get user with uuid %s on PlayerJoinEvent", uuid); + return null; + }); + } + + @EventHandler + void onPlayerQuit(PlayerQuitEvent event) { + final Player player = event.getPlayer(); + final UUID uuid = player.getUniqueId(); + final PlayTime playTime = playTimeAdapter.read(player); + + userService.setPlayTime(uuid, playTime) + .exceptionally(e -> { + logger.error(e, "Failed to set user playTime with uuid %s on PlayerQuitEvent", uuid); + return null; + }); + } + +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUser.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUser.java new file mode 100644 index 0000000..fb713b9 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUser.java @@ -0,0 +1,59 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.github.imdmk.playtime.PlayTime; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.UUID; + +public final class PlayTimeUser { + + private final UUID uuid; + private PlayTime playTime; + + PlayTimeUser(@NotNull UUID uuid) { + this.uuid = uuid; + this.playTime = PlayTime.ZERO; + } + + PlayTimeUser(@NotNull UUID uuid, @NotNull PlayTime playTime) { + this.uuid = uuid; + this.playTime = playTime; + } + + @NotNull + public UUID getUuid() { + return uuid; + } + + @NotNull + public PlayTime getPlayTime() { + return playTime; + } + + public void setPlayTime(@NotNull PlayTime playTime) { + this.playTime = playTime; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof PlayTimeUser that)) { + return false; + } + + return Objects.equals(uuid, that.uuid); + } + + @Override + public int hashCode() { + return uuid.hashCode(); + } + + @Override + public String toString() { + return "PlayTimeUser{" + + "uuid=" + uuid + + ", playTime=" + playTime + + '}'; + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserCache.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserCache.java new file mode 100644 index 0000000..a06e3e5 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserCache.java @@ -0,0 +1,36 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.injector.subscriber.Subscribe; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeShutdownEvent; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Service(priority = ComponentPriority.LOWEST) +final class PlayTimeUserCache { + + private final Map cache = new ConcurrentHashMap<>(); + + public void put(@NotNull UUID uuid, @NotNull PlayTimeUser user) { + cache.put(uuid, user); + } + + public Optional get(@NotNull UUID uuid) { + return Optional.ofNullable(cache.get(uuid)); + } + + void remove(@NotNull UUID uuid) { + cache.remove(uuid); + } + + @Subscribe(event = PlayTimeShutdownEvent.class) + private void shutdown() { + cache.clear(); + } +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserEntity.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserEntity.java new file mode 100644 index 0000000..ade9dc0 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserEntity.java @@ -0,0 +1,68 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.j256.ormlite.field.DatabaseField; +import com.j256.ormlite.table.DatabaseTable; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +@DatabaseTable(tableName = PlayTimeUserEntityMeta.TABLE) +final class PlayTimeUserEntity { + + @DatabaseField(id = true, canBeNull = false, columnName = PlayTimeUserEntityMeta.Col.UUID) + private UUID uuid; + + @DatabaseField(canBeNull = false, columnName = PlayTimeUserEntityMeta.Col.PLAYTIME_MILLIS) + private long playtimeMillis; + + PlayTimeUserEntity() {} + + PlayTimeUserEntity( + @NotNull UUID uuid, + long playtimeMillis + ) { + this.uuid = uuid; + this.playtimeMillis = playtimeMillis; + } + + public UUID getUuid() { + return this.uuid; + } + + public void setUuid(@NotNull UUID uuid) { + this.uuid = uuid; + } + + public long getPlayTimeMillis() { + return playtimeMillis; + } + + public void setPlayTimeMillis(long playtimeMillis) { + this.playtimeMillis = playtimeMillis; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PlayTimeUserEntity other)) { + return false; + } + + return uuid.equals(other.uuid); + } + + @Override + public int hashCode() { + return uuid.hashCode(); + } + + @Override + public String toString() { + return "UserEntity{" + + "uuid=" + this.uuid + + ", spentMillis=" + this.playtimeMillis + + '}'; + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserEntityMapper.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserEntityMapper.java new file mode 100644 index 0000000..1de94fe --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserEntityMapper.java @@ -0,0 +1,25 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.github.imdmk.playtime.PlayTime; +import com.github.imdmk.playtime.database.repository.ormlite.EntityMapper; +import org.jetbrains.annotations.NotNull; + +final class PlayTimeUserEntityMapper + implements EntityMapper { + + @Override + public PlayTimeUserEntity toEntity(@NotNull PlayTimeUser user) { + return new PlayTimeUserEntity( + user.getUuid(), + user.getPlayTime().millis() + ); + } + + @Override + public PlayTimeUser toDomain(@NotNull PlayTimeUserEntity entity) { + return new PlayTimeUser( + entity.getUuid(), + PlayTime.ofMillis(entity.getPlayTimeMillis()) + ); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserEntityMeta.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserEntityMeta.java new file mode 100644 index 0000000..79f1c65 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserEntityMeta.java @@ -0,0 +1,16 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.github.imdmk.playtime.database.repository.ormlite.EntityMeta; + +interface PlayTimeUserEntityMeta extends EntityMeta { + + String TABLE = "advanced_playtime_users"; + + interface Col { + + String UUID = "uuid"; + + String PLAYTIME_MILLIS = "playtimeMillis"; + + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserFactory.java deleted file mode 100644 index 03b7552..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserFactory.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime; - -import com.github.imdmk.playtime.PlaytimeService; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserFactory; -import com.github.imdmk.playtime.user.UserTime; -import org.bukkit.OfflinePlayer; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.util.Optional; -import java.util.UUID; - -/** - * Concrete implementation of {@link UserFactory} that constructs {@link User} instances - * using data retrieved from the {@link PlaytimeService}. - * - *

This factory supports both online and offline players, resolving their unique identifiers, - * last known names, and total recorded playtime from the underlying service.

- * - *

Dependency: {@link PlaytimeService} is injected at runtime and must be available - * before this factory is used.

- * - * @see User - * @see PlaytimeService - * @see UserFactory - */ -public final class PlayTimeUserFactory implements UserFactory { - - private static final String UNKNOWN_PLAYER_NAME_FORMAT = "Unknown:%s"; - - private final PlaytimeService playtimeService; - - @Inject - public PlayTimeUserFactory(@NotNull PlaytimeService playtimeService) { - this.playtimeService = Validator.notNull(playtimeService, "playtimeService"); - } - - /** - * Creates a {@link User} instance from an online {@link Player}. - * - *

The user's UUID and current name are taken directly from the live {@link Player} object, - * and their total playtime is resolved via the {@link PlaytimeService}.

- * - * @param player non-null online player instance - * @return new {@link User} representing the given player and their current playtime - * @throws NullPointerException if {@code player} is null - */ - @Override - public @NotNull User createFrom(@NotNull Player player) { - Validator.notNull(player, "player cannot be null"); - - final UUID uuid = player.getUniqueId(); - final String name = player.getName(); - final UserTime time = playtimeService.getTime(uuid); - - return new User(uuid, name, time); - } - - /** - * Creates a {@link User} instance from an {@link OfflinePlayer}. - * - *

If the player's name cannot be resolved (e.g. first join or data missing), - * a default placeholder name {@code "Unknown"} is used instead. - * The total playtime is fetched from {@link PlaytimeService} based on the player's UUID.

- * - * @param player non-null offline player instance - * @return new {@link User} representing the offline player and their playtime data - * @throws NullPointerException if {@code player} is null - */ - @Override - public @NotNull User createFrom(@NotNull OfflinePlayer player) { - Validator.notNull(player, "player cannot be null"); - - final UUID uuid = player.getUniqueId(); - final String name = Optional.ofNullable(player.getName()).orElse(UNKNOWN_PLAYER_NAME_FORMAT.formatted(uuid)); - final UserTime time = playtimeService.getTime(uuid); - - return new User(uuid, name, time); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserRepository.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserRepository.java new file mode 100644 index 0000000..7ff733e --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserRepository.java @@ -0,0 +1,19 @@ +package com.github.imdmk.playtime.feature.playtime; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public interface PlayTimeUserRepository { + + CompletableFuture> findByUuid(@NotNull UUID uuid); + CompletableFuture> findTopByPlayTime(int limit); + CompletableFuture> findAll(); + + CompletableFuture deleteByUuid(@NotNull UUID uuid); + + CompletableFuture save(@NotNull PlayTimeUser user); +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserRepositoryOrmLite.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserRepositoryOrmLite.java new file mode 100644 index 0000000..d41386c --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserRepositoryOrmLite.java @@ -0,0 +1,114 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.github.imdmk.playtime.database.DatabaseBootstrap; +import com.github.imdmk.playtime.database.repository.ormlite.OrmLiteRepository; +import com.github.imdmk.playtime.injector.annotations.Repository; +import com.github.imdmk.playtime.platform.logger.PluginLogger; +import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +@Repository +final class PlayTimeUserRepositoryOrmLite + extends OrmLiteRepository + implements PlayTimeUserRepository { + + private final PlayTimeUserEntityMapper mapper; + + @Inject + PlayTimeUserRepositoryOrmLite( + @NotNull PluginLogger logger, + @NotNull TaskScheduler taskScheduler, + @NotNull DatabaseBootstrap databaseBootstrap + ) { + super(logger, taskScheduler, databaseBootstrap); + this.mapper = new PlayTimeUserEntityMapper(); + } + + @Override + protected Class entityClass() { + return PlayTimeUserEntity.class; + } + + @Override + public CompletableFuture> findByUuid(@NotNull UUID uuid) { + return execute(() -> { + try { + return Optional.ofNullable(dao.queryForId(uuid)) + .map(mapper::toDomain); + } catch (SQLException e) { + logger.error(e, "Failed to find user with uuid %s", uuid); + throw new IllegalStateException("Database failure", e); + } + }); + } + + @Override + public CompletableFuture> findTopByPlayTime(int limit) { + if (limit <= 0) { + return CompletableFuture.completedFuture(List.of()); + } + + return execute(() -> { + try { + return dao.queryBuilder() + .orderBy(PlayTimeUserEntityMeta.Col.PLAYTIME_MILLIS, false) + .limit((long) limit) + .query() + .stream() + .map(mapper::toDomain) + .toList(); + } catch (SQLException e) { + logger.error(e, "Failed to query top playtime users (limit=%d)", limit); + throw new IllegalStateException("Database failure", e); + } + }); + } + + + @Override + public CompletableFuture> findAll() { + return execute(() -> { + try { + return dao.queryForAll().stream() + .map(mapper::toDomain) + .toList(); + } catch (SQLException e) { + logger.error(e, "Failed to query all users"); + throw new IllegalStateException("Database failure", e); + } + }); + } + + @Override + public CompletableFuture deleteByUuid(@NotNull UUID uuid) { + return execute(() -> { + try { + final int rows = dao.deleteById(uuid); + return rows > 0; + } catch (SQLException e) { + logger.error(e, "Failed to delete user with uuid %s", uuid); + throw new IllegalStateException("Database failure", e); + } + }); + } + + @Override + public CompletableFuture save(@NotNull PlayTimeUser user) { + return execute(() -> { + try { + dao.createOrUpdate(mapper.toEntity(user)); + return null; + } catch (SQLException e) { + logger.error(e, "Failed to save user with uuid %s", user.getUuid()); + throw new IllegalStateException("Database failure", e); + } + }); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserService.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserService.java new file mode 100644 index 0000000..4981dbb --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/PlayTimeUserService.java @@ -0,0 +1,77 @@ +package com.github.imdmk.playtime.feature.playtime; + +import com.github.imdmk.playtime.PlayTime; +import com.github.imdmk.playtime.injector.annotations.Service; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +@Service +final class PlayTimeUserService { + + private final PlayTimeUserCache cache; + private final PlayTimeUserRepository repository; + + @Inject + PlayTimeUserService(@NotNull PlayTimeUserCache cache, @NotNull PlayTimeUserRepository repository) { + this.cache = cache; + this.repository = repository; + } + + CompletableFuture getOrCreate( + @NotNull UUID uuid, + @NotNull PlayTime initialPlayTime + ) { + return cache.get(uuid) + .map(CompletableFuture::completedFuture) + .orElseGet(() -> repository.findByUuid(uuid) + .thenCompose(optional -> { + if (optional.isPresent()) { + return CompletableFuture.completedFuture(optional.get()); + } + + final PlayTimeUser user = new PlayTimeUser(uuid, initialPlayTime); + return repository.save(user) + .thenApply(v -> user); + })); + + } + + CompletableFuture getOrCreate(@NotNull UUID uuid) { + return getOrCreate(uuid, PlayTime.ZERO); + } + + CompletableFuture getPlayTime(@NotNull UUID uuid) { + return getOrCreate(uuid) + .thenApply(PlayTimeUser::getPlayTime); + } + + CompletableFuture setPlayTime( + @NotNull UUID uuid, + @NotNull PlayTime playTime + ) { + return getOrCreate(uuid) + .thenCompose(user -> { + user.setPlayTime(playTime); + return repository.save(user); + }); + } + + CompletableFuture addPlayTime( + @NotNull UUID uuid, + @NotNull PlayTime delta + ) { + return getOrCreate(uuid) + .thenCompose(user -> { + user.setPlayTime(user.getPlayTime().plus(delta)); + return repository.save(user); + }); + } + + CompletableFuture delete(@NotNull UUID uuid) { + return repository.deleteByUuid(uuid); + } +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeCommand.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeCommand.java deleted file mode 100644 index b97e2f0..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeCommand.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime.command; - -import com.github.imdmk.playtime.PlaytimeService; -import com.github.imdmk.playtime.message.MessageService; -import com.github.imdmk.playtime.shared.time.Durations; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserTime; -import dev.rollczi.litecommands.annotations.argument.Arg; -import dev.rollczi.litecommands.annotations.async.Async; -import dev.rollczi.litecommands.annotations.command.Command; -import dev.rollczi.litecommands.annotations.context.Context; -import dev.rollczi.litecommands.annotations.execute.Execute; -import dev.rollczi.litecommands.annotations.permission.Permission; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -@Command(name = "playtime") -public final class TimeCommand { - - private final MessageService messageService; - private final PlaytimeService playtimeService; - - @Inject - public TimeCommand( - @NotNull MessageService messageService, - @NotNull PlaytimeService playtimeService - ) { - this.messageService = Validator.notNull(messageService, "messageService cannot be null"); - this.playtimeService = Validator.notNull(playtimeService, "playtimeService cannot be null"); - } - - @Execute - @Permission("command.playtime") - void selfPlaytime(@Context Player viewer) { - final UserTime time = playtimeService.getTime(viewer.getUniqueId()); - - messageService.create() - .notice(n -> n.playtimeMessages.playerPlaytimeSelf()) - .viewer(viewer) - .placeholder("{PLAYER_PLAYTIME}", Durations.format(time.toDuration())) - .send(); - } - - @Execute - @Permission("command.playtime.target") - void targetPlaytime(@Context Player viewer, @Arg @Async User target) { - final UserTime time = playtimeService.getTime(target.getUuid()); - - messageService.create() - .notice(n -> n.playtimeMessages.playerPlaytimeTarget()) - .viewer(viewer) - .placeholder("{PLAYER_NAME}", target.getName()) - .placeholder("{PLAYER_PLAYTIME}", Durations.format(time.toDuration())) - .send(); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeResetAllCommand.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeResetAllCommand.java deleted file mode 100644 index 734463e..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeResetAllCommand.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime.command; - -import com.github.imdmk.playtime.PlaytimeService; -import com.github.imdmk.playtime.message.MessageService; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserSaveReason; -import com.github.imdmk.playtime.user.UserService; -import com.github.imdmk.playtime.user.UserTime; -import com.github.imdmk.playtime.user.repository.UserRepository; -import dev.rollczi.litecommands.annotations.command.Command; -import dev.rollczi.litecommands.annotations.context.Context; -import dev.rollczi.litecommands.annotations.execute.Execute; -import dev.rollczi.litecommands.annotations.permission.Permission; -import org.bukkit.OfflinePlayer; -import org.bukkit.Server; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.util.Arrays; -import java.util.concurrent.CompletableFuture; - -@Command(name = "playtime reset-all") -@Permission("command.playtime.reset.all") -public final class TimeResetAllCommand { - - private static final UserSaveReason SAVE_REASON = UserSaveReason.RESET_COMMAND; - - private final Server server; - private final PluginLogger logger; - private final MessageService messageService; - private final PlaytimeService playtimeService; - private final UserService userService; - private final UserRepository userRepository; - private final TaskScheduler taskScheduler; - - @Inject - public TimeResetAllCommand( - @NotNull Server server, - @NotNull PluginLogger logger, - @NotNull MessageService messageService, - @NotNull PlaytimeService playtimeService, - @NotNull UserService userService, - @NotNull UserRepository userRepository, - @NotNull TaskScheduler taskScheduler - ) { - this.server = Validator.notNull(server, "server cannot be null"); - this.logger = Validator.notNull(logger, "logger cannot be null"); - this.messageService = Validator.notNull(messageService, "messageService cannot be null"); - this.playtimeService = Validator.notNull(playtimeService, "playtimeService cannot be null"); - this.userService = Validator.notNull(userService, "userService cannot be null"); - this.userRepository = Validator.notNull(userRepository, "userRepository cannot be null"); - this.taskScheduler = Validator.notNull(taskScheduler, "taskScheduler cannot be null"); - } - - @Execute - void resetAll(@Context CommandSender sender) { - messageService.send(sender, n -> n.playtimeMessages.playerPlaytimeResetAllStarted()); - - taskScheduler.runSync(this::resetOfflinePlayers); - userRepository.findAll() - .thenCompose(users -> { - if (users.isEmpty()) { - return CompletableFuture.completedFuture(false); - } - - return CompletableFuture.allOf(users.stream() - .map(this::resetUser) - .toArray(CompletableFuture[]::new)) - .thenApply(v -> true); - }) - .whenComplete((b, e) -> { - if (e != null) { - logger.error(e, "Failed to reset playtime for all users"); - messageService.send(sender, n -> n.playtimeMessages.playerPlaytimeResetAllFailed()); - return; - } - - messageService.send(sender, n -> n.playtimeMessages.playerPlaytimeResetAllFinished()); - }); - } - - private CompletableFuture resetUser(@NotNull User user) { - user.setPlaytime(UserTime.ZERO); - return userService.save(user, SAVE_REASON); - } - - private void resetOfflinePlayers() { - Arrays.stream(server.getOfflinePlayers()) - .map(OfflinePlayer::getUniqueId) - .forEach(playtimeService::resetTime); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeResetCommand.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeResetCommand.java deleted file mode 100644 index a48e233..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeResetCommand.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime.command; - -import com.github.imdmk.playtime.message.MessageService; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserSaveReason; -import com.github.imdmk.playtime.user.UserService; -import com.github.imdmk.playtime.user.UserTime; -import dev.rollczi.litecommands.annotations.argument.Arg; -import dev.rollczi.litecommands.annotations.async.Async; -import dev.rollczi.litecommands.annotations.command.Command; -import dev.rollczi.litecommands.annotations.context.Context; -import dev.rollczi.litecommands.annotations.execute.Execute; -import dev.rollczi.litecommands.annotations.permission.Permission; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -@Command(name = "playtime reset") -@Permission("command.playtime.reset") -public final class TimeResetCommand { - - private static final UserSaveReason SAVE_REASON = UserSaveReason.RESET_COMMAND; - - private final PluginLogger logger; - private final MessageService messageService; - private final UserService userService; - - @Inject - public TimeResetCommand( - @NotNull PluginLogger logger, - @NotNull MessageService messageService, - @NotNull UserService userService - ) { - this.logger = Validator.notNull(logger, "logger cannot be null"); - this.messageService = Validator.notNull(messageService, "messageService cannot be null"); - this.userService = Validator.notNull(userService, "userService cannot be null"); - } - - @Execute - void reset(@Context CommandSender sender, @Arg @Async User target) { - target.setPlaytime(UserTime.ZERO); - - userService.save(target, SAVE_REASON) - .thenAccept(user -> messageService.create() - .viewer(sender) - .notice(n -> n.playtimeMessages.playerPlaytimeReset()) - .placeholder("{PLAYER_NAME}", user.getName()) - .send()) - .exceptionally(e -> { - logger.error(e, "An error occurred while trying to reset user playtime"); - messageService.send(sender, n -> n.actionExecutionError); - return null; - }); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeSetCommand.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeSetCommand.java deleted file mode 100644 index 4b01fa6..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeSetCommand.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime.command; - -import com.github.imdmk.playtime.message.MessageService; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.time.Durations; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserSaveReason; -import com.github.imdmk.playtime.user.UserService; -import com.github.imdmk.playtime.user.UserTime; -import dev.rollczi.litecommands.annotations.argument.Arg; -import dev.rollczi.litecommands.annotations.async.Async; -import dev.rollczi.litecommands.annotations.command.Command; -import dev.rollczi.litecommands.annotations.context.Context; -import dev.rollczi.litecommands.annotations.execute.Execute; -import dev.rollczi.litecommands.annotations.permission.Permission; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.time.Duration; - -@Command(name = "playtime set") -@Permission("command.playtime.set") -public final class TimeSetCommand { - - private final PluginLogger logger; - private final MessageService messageService; - private final UserService userService; - - @Inject - public TimeSetCommand( - @NotNull PluginLogger logger, - @NotNull MessageService messageService, - @NotNull UserService userService - ) { - this.logger = Validator.notNull(logger, "logger cannot be null"); - this.messageService = Validator.notNull(messageService, "messageService cannot be null"); - this.userService = Validator.notNull(userService, "userService cannot be null"); - } - - @Execute - void setPlaytime(@Context CommandSender sender, @Arg @Async User target, @Arg Duration time) { - final Duration normalizedTime = Durations.clamp(time); - final UserTime newTime = UserTime.ofDuration(normalizedTime); - - target.setPlaytime(newTime); - - userService.save(target, UserSaveReason.SET_COMMAND) - .thenAccept(v -> messageService.create() - .notice(n -> n.playtimeMessages.playerPlaytimeUpdated()) - .placeholder("{PLAYER_NAME}", target.getName()) - .placeholder("{PLAYER_PLAYTIME}", Durations.format(normalizedTime)) - .viewer(sender) - .send() - ) - .exceptionally(e -> { - logger.error(e, "Failed to save user on playtime set command (target=%s)", target.getName()); - messageService.send(sender, n -> n.actionExecutionError); - return null; - }); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeTopCommand.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeTopCommand.java deleted file mode 100644 index 6c2ca89..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeTopCommand.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime.command; - -import com.github.imdmk.playtime.feature.playtime.gui.PlayTimeTopGui; -import com.github.imdmk.playtime.message.MessageService; -import com.github.imdmk.playtime.platform.gui.view.GuiOpener; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.UserService; -import dev.rollczi.litecommands.annotations.command.Command; -import dev.rollczi.litecommands.annotations.context.Context; -import dev.rollczi.litecommands.annotations.execute.Execute; -import dev.rollczi.litecommands.annotations.permission.Permission; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -@Command(name = "playtime top") -@Permission("command.playtime.top") -public final class TimeTopCommand { - - private static final int TOP_QUERY_LIMIT = 100; - - private final PluginLogger logger; - private final MessageService messageService; - private final UserService userService; - private final GuiOpener guiOpener; - - @Inject - public TimeTopCommand( - @NotNull PluginLogger logger, - @NotNull MessageService messageService, - @NotNull UserService userService, - @NotNull GuiOpener guiOpener - ) { - this.logger = Validator.notNull(logger, "logger cannot be null"); - this.messageService = Validator.notNull(messageService, "messageService cannot be null"); - this.userService = Validator.notNull(userService, "userService cannot be null"); - this.guiOpener = Validator.notNull(guiOpener, "guiOpener cannot be null"); - } - - @Execute - void playtimeTop(@Context Player viewer) { - userService.findTopByPlayTime(TOP_QUERY_LIMIT) - .thenAccept(topUsers -> guiOpener.open(PlayTimeTopGui.class, viewer, topUsers)) - .exceptionally(e -> { - logger.error(e, "Failed to open PlaytimeTopGui for viewer=%s", viewer.getName()); - this.messageService.send(viewer, n -> n.actionExecutionError); - return null; - }); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeTopInvalidateCommand.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeTopInvalidateCommand.java deleted file mode 100644 index 78f5dc9..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/command/TimeTopInvalidateCommand.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime.command; - -import com.github.imdmk.playtime.message.MessageService; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.top.TopUsersCache; -import dev.rollczi.litecommands.annotations.command.Command; -import dev.rollczi.litecommands.annotations.context.Context; -import dev.rollczi.litecommands.annotations.execute.Execute; -import dev.rollczi.litecommands.annotations.permission.Permission; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -@Command(name = "playtime top invalidate") -@Permission("command.playtime.top.invalidate") -public final class TimeTopInvalidateCommand { - - private final MessageService messageService; - private final TopUsersCache topUsersCache; - - @Inject - public TimeTopInvalidateCommand( - @NotNull MessageService messageService, - @NotNull TopUsersCache topUsersCache - ) { - this.messageService = Validator.notNull(messageService, "messageService cannot be null"); - this.topUsersCache = Validator.notNull(topUsersCache, "topUsersCache cannot be null"); - } - - @Execute - void invalidateCache(@Context CommandSender sender) { - topUsersCache.invalidateAll(); - messageService.send(sender, n -> n.playtimeMessages.topUsersCacheInvalidated()); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/gui/PlayTimeTopGui.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/gui/PlayTimeTopGui.java deleted file mode 100644 index 004cc9e..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/gui/PlayTimeTopGui.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime.gui; - -import com.github.imdmk.playtime.message.MessageService; -import com.github.imdmk.playtime.platform.adventure.AdventureFormatter; -import com.github.imdmk.playtime.platform.adventure.AdventurePlaceholders; -import com.github.imdmk.playtime.platform.gui.GuiType; -import com.github.imdmk.playtime.platform.gui.config.NavigationBarConfig; -import com.github.imdmk.playtime.platform.gui.factory.GuiFactory; -import com.github.imdmk.playtime.platform.gui.item.ItemGui; -import com.github.imdmk.playtime.platform.gui.item.ItemGuiTransformer; -import com.github.imdmk.playtime.platform.gui.item.ItemVariantPermissionResolver; -import com.github.imdmk.playtime.platform.gui.item.ItemVariantResolver; -import com.github.imdmk.playtime.platform.gui.render.GuiRenderer; -import com.github.imdmk.playtime.platform.gui.render.RenderContext; -import com.github.imdmk.playtime.platform.gui.render.RenderOptions; -import com.github.imdmk.playtime.platform.gui.render.TriumphGuiRenderer; -import com.github.imdmk.playtime.platform.gui.view.AbstractGui; -import com.github.imdmk.playtime.platform.gui.view.ParameterizedGui; -import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; -import com.github.imdmk.playtime.shared.time.Durations; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserSaveReason; -import com.github.imdmk.playtime.user.UserService; -import com.github.imdmk.playtime.user.UserTime; -import dev.triumphteam.gui.builder.item.BaseItemBuilder; -import dev.triumphteam.gui.builder.item.SkullBuilder; -import dev.triumphteam.gui.guis.BaseGui; -import org.bukkit.Server; -import org.bukkit.entity.Player; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.util.List; -import java.util.function.Consumer; - -public final class PlayTimeTopGui - extends AbstractGui - implements ParameterizedGui> { - - private static final String GUI_IDENTIFIER = "playtime-top"; - private static final UserSaveReason SAVE_REASON = UserSaveReason.GUI_RESET_CLICK; - - private static final GuiRenderer GUI_RENDERER = TriumphGuiRenderer.newRenderer(); - private static final RenderOptions RENDER_OPTIONS = RenderOptions.defaultHide(); - private static final ItemVariantResolver ITEM_VARIANT_RESOLVER = new ItemVariantPermissionResolver(); - - private final Server server; - private final PlayTimeTopGuiConfig topGuiConfig; - private final MessageService messageService; - private final UserService userService; - - @Inject - public PlayTimeTopGui( - @NotNull Server server, - @NotNull NavigationBarConfig navigationBarConfig, - @NotNull PlayTimeTopGuiConfig topGuiConfig, - @NotNull TaskScheduler taskScheduler, - @NotNull MessageService messageService, - @NotNull UserService userService - ) { - super(navigationBarConfig, taskScheduler, GUI_RENDERER, RENDER_OPTIONS); - this.server = Validator.notNull(server, "server"); - this.topGuiConfig = Validator.notNull(topGuiConfig, "playtimeTopGuiConfig cannot be null"); - this.messageService = Validator.notNull(messageService, "messageService cannot be null"); - this.userService = Validator.notNull(userService, "userService cannot be null"); - } - - @Override - public @NotNull BaseGui createGui(@NotNull Player viewer, @NotNull List users) { - Validator.notNull(viewer, "viewer cannot be null"); - Validator.notNull(users, "users cannot be null"); - return GuiFactory.build(topGuiConfig, BaseGui::disableAllInteractions); - } - - @Override - public void prepareItems(@NotNull BaseGui gui, @NotNull Player viewer, @NotNull List users) { - Validator.notNull(gui, "gui cannot be null"); - Validator.notNull(viewer, "viewer cannot be null"); - Validator.notNull(users, "users cannot be null"); - - if (topGuiConfig.fillBorder) { - final var border = ItemGuiTransformer.toGuiItem(topGuiConfig.borderItem); - gui.getFiller().fillBorder(border); - } - - placeExit(gui, viewer, e -> gui.close(viewer)); - - if (topGuiConfig.type == GuiType.PAGINATED) { - placeNext(gui, viewer); - placePrevious(gui, viewer); - } - - final var context = RenderContext.defaultContext(viewer); - final var item = resolveItemFor(viewer, context); - - for (int i = 0; i < users.size(); i++) { - final User user = users.get(i); - final int position = i + 1; - - final var placeholders = createPlaceholders(user, position); - - final Consumer onClick = (click) -> { - if (click.getClick() != topGuiConfig.resetClickType) { - return; - } - - gui.close(viewer); - - user.setPlaytime(UserTime.ZERO); - userService.save(user, SAVE_REASON) - .thenAccept(result -> messageService.send(viewer, n -> n.playtimeMessages.playerPlaytimeReset())) - .exceptionally(e -> { - messageService.send(viewer, n -> n.actionExecutionError); - return null; - }); - }; - - final Consumer> editor = (builder) -> { - if (builder instanceof SkullBuilder skullBuilder) { - skullBuilder.owner(server.getOfflinePlayer(user.getUuid())); - } - - builder.name(AdventureFormatter.format(item.name(), placeholders)); - builder.lore(AdventureFormatter.format(item.lore(), placeholders)); - }; - - renderer.addItem(gui, item, context, renderOptions, onClick, editor); - } - } - - private ItemGui resolveItemFor(Player viewer, RenderContext context) { - final var adminItem = topGuiConfig.playerEntryAdminItem; - final var item = topGuiConfig.playerEntryItem; - - return ITEM_VARIANT_RESOLVER.resolve(viewer, context, List.of(adminItem), item); - } - - private AdventurePlaceholders createPlaceholders(User topUser, int position) { - return AdventurePlaceholders.builder() - .with("{PLAYER_NAME}", topUser.getName()) - .with("{PLAYER_POSITION}", position) - .with("{PLAYER_PLAYTIME}", Durations.format(topUser.getPlaytime().toDuration())) - .with("{CLICK_RESET}", topGuiConfig.resetClickType.name()) - .build(); - } - - @Override - public @NotNull String getId() { - return GUI_IDENTIFIER; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/listener/PlayTimeSaveListener.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/listener/PlayTimeSaveListener.java deleted file mode 100644 index d1d0a78..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/listener/PlayTimeSaveListener.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime.listener; - -import com.github.imdmk.playtime.PlaytimeService; -import com.github.imdmk.playtime.UserPreSaveEvent; -import com.github.imdmk.playtime.UserSaveEvent; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserSaveReason; -import com.github.imdmk.playtime.user.UserTime; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.util.UUID; - -public final class PlayTimeSaveListener implements Listener { - - private final PlaytimeService playtimeService; - - @Inject - public PlayTimeSaveListener(@NotNull PlaytimeService playtimeService) { - this.playtimeService = Validator.notNull(playtimeService, "playtimeService cannot be null"); - } - - @EventHandler(priority = EventPriority.LOWEST) - public void onUserPreSave(UserPreSaveEvent event) { - final User user = event.getUser(); - final UUID uuid = user.getUuid(); - final UserSaveReason reason = event.getReason(); - - if (reason == UserSaveReason.PLAYER_LEAVE) { - final UserTime currentPlaytime = playtimeService.getTime(uuid); - user.setPlaytime(currentPlaytime); - } - } - - @EventHandler(priority = EventPriority.HIGHEST) - public void onUserSave(UserSaveEvent event) { - final User user = event.getUser(); - playtimeService.setTime(user.getUuid(), user.getPlaytime()); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/messages/ENPlayTimeMessages.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/messages/ENPlayTimeMessages.java index b4e1ff4..af98816 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/messages/ENPlayTimeMessages.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/messages/ENPlayTimeMessages.java @@ -4,7 +4,9 @@ import eu.okaeri.configs.OkaeriConfig; import eu.okaeri.configs.annotation.Comment; -public final class ENPlayTimeMessages extends OkaeriConfig implements PlayTimeMessages { +public final class ENPlayTimeMessages + extends OkaeriConfig + implements PlayTimeMessages { @Comment({ "#", @@ -14,7 +16,7 @@ public final class ENPlayTimeMessages extends OkaeriConfig implements PlayTimeMe "# {PLAYER_PLAYTIME} - formatted playtime of the player (e.g. 5h 32m).", "#" }) - Notice playerPlaytimeSelf = Notice.chat( + Notice playerPlayTimeSelf = Notice.chat( "You have spent {PLAYER_PLAYTIME} on this server." ); @@ -23,11 +25,10 @@ public final class ENPlayTimeMessages extends OkaeriConfig implements PlayTimeMe "# Sent to a command executor when they check another player's playtime.", "#", "# Placeholders:", - "# {PLAYER_NAME} - target player's nickname.", "# {PLAYER_PLAYTIME} - formatted playtime of the target player.", "#" }) - Notice playerPlaytimeTarget = Notice.chat( + Notice playerPlayTimeTarget = Notice.chat( "Player {PLAYER_NAME} has spent {PLAYER_PLAYTIME} on this server." ); @@ -36,100 +37,25 @@ public final class ENPlayTimeMessages extends OkaeriConfig implements PlayTimeMe "# Sent to a command executor after manually setting a player's playtime.", "#", "# Placeholders:", - "# {PLAYER_NAME} - target player's nickname.", "# {PLAYER_PLAYTIME} - new formatted playtime value.", "#" }) - Notice playerPlaytimeUpdated = Notice.chat( - "Updated playtime for player {PLAYER_NAME} to {PLAYER_PLAYTIME}." + Notice playerPlayTimeUpdated = Notice.chat( + "Updated playtime for player to {PLAYER_PLAYTIME}." ); - @Comment({ - "#", - "# Sent to a command executor after resetting a single player's playtime to zero.", - "#", - "# Placeholders:", - "# {PLAYER_NAME} - target player's nickname.", - "#" - }) - Notice playerPlaytimeReset = Notice.chat( - "Playtime for player {PLAYER_NAME} has been reset to ZERO." - ); - - @Comment({ - "#", - "# Sent when a global reset of all players' playtime is triggered.", - "#" - }) - Notice playerPlaytimeResetAllStarted = Notice.chat( - "Global playtime reset started... Please wait." - ); - - @Comment({ - "#", - "# Sent to the executor if the global playtime reset fails.", - "#" - }) - Notice playerPlaytimeResetAllFailed = Notice.chat( - "An error occurred while resetting playtime for all players. " - + "Check console for details." - ); - - @Comment({ - "#", - "# Sent when the global playtime reset finishes successfully.", - "#" - }) - Notice playerPlaytimeResetAllFinished = Notice.chat( - "Successfully reset playtime for all stored players." - ); - - @Comment({ - "#", - "# Sent after invalidating the Top users playtime cache.", - "#" - }) - Notice topUsersCacheInvalidated = Notice.chat( - "Successfully invalidated the PlayTime Top cache." - ); - - @Override - public Notice playerPlaytimeSelf() { - return playerPlaytimeSelf; - } - - @Override - public Notice playerPlaytimeTarget() { - return playerPlaytimeTarget; - } - - @Override - public Notice playerPlaytimeUpdated() { - return playerPlaytimeUpdated; - } - - @Override - public Notice playerPlaytimeReset() { - return playerPlaytimeReset; - } - - @Override - public Notice playerPlaytimeResetAllStarted() { - return playerPlaytimeResetAllStarted; - } - @Override - public Notice playerPlaytimeResetAllFailed() { - return playerPlaytimeResetAllFailed; + public Notice playerPlayTimeSelf() { + return playerPlayTimeSelf; } @Override - public Notice playerPlaytimeResetAllFinished() { - return playerPlaytimeResetAllFinished; + public Notice playerPlayTimeTarget() { + return playerPlayTimeTarget; } @Override - public Notice topUsersCacheInvalidated() { - return topUsersCacheInvalidated; + public Notice playerPlayTimeUpdated() { + return playerPlayTimeUpdated; } -} +} \ No newline at end of file diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/messages/PlayTimeMessages.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/messages/PlayTimeMessages.java index 070db18..152834e 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/messages/PlayTimeMessages.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/messages/PlayTimeMessages.java @@ -4,15 +4,8 @@ public interface PlayTimeMessages { - Notice playerPlaytimeSelf(); - Notice playerPlaytimeTarget(); + Notice playerPlayTimeSelf(); + Notice playerPlayTimeTarget(); + Notice playerPlayTimeUpdated(); - Notice playerPlaytimeUpdated(); - Notice playerPlaytimeReset(); - - Notice playerPlaytimeResetAllStarted(); - Notice playerPlaytimeResetAllFailed(); - Notice playerPlaytimeResetAllFinished(); - - Notice topUsersCacheInvalidated(); } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/placeholder/PlayTimePlaceholder.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/placeholder/PlayTimePlaceholder.java deleted file mode 100644 index 1f3a1f3..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/placeholder/PlayTimePlaceholder.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.github.imdmk.playtime.feature.playtime.placeholder; - -import com.github.imdmk.playtime.PlaytimeService; -import com.github.imdmk.playtime.platform.placeholder.PluginPlaceholder; -import com.github.imdmk.playtime.shared.time.Durations; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.bukkit.OfflinePlayer; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.util.UUID; - -public final class PlayTimePlaceholder implements PluginPlaceholder { - - private final PlaytimeService playtimeService; - - @Inject - public PlayTimePlaceholder(@NotNull PlaytimeService playtimeService) { - this.playtimeService = Validator.notNull(playtimeService, "playtimeService cannot be null"); - } - - @Override - public @NotNull String identifier() { - return "advancedplaytime"; - } - - @Override - public @NotNull String onRequest(@NotNull Player player, @NotNull String params) { - return formatUserTime(player.getUniqueId()); - } - - @Override - public @NotNull String onRequest(@NotNull OfflinePlayer player, @NotNull String params) { - return formatUserTime(player.getUniqueId()); - } - - private @NotNull String formatUserTime(@NotNull UUID uuid) { - return Durations.format(playtimeService.getTime(uuid).toDuration()); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/CachedPlayTimeTop.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/CachedPlayTimeTop.java new file mode 100644 index 0000000..1284ab7 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/CachedPlayTimeTop.java @@ -0,0 +1,18 @@ +package com.github.imdmk.playtime.feature.playtime.top; + +import com.github.imdmk.playtime.feature.playtime.PlayTimeUser; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +record CachedPlayTimeTop( + @NotNull List users, + @NotNull Instant loadedAt +) { + boolean isExpired(@NotNull Duration ttl, @NotNull Instant now) { + return ttl.isPositive() && loadedAt.plus(ttl).isBefore(now); + } +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopCache.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopCache.java new file mode 100644 index 0000000..7e63812 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopCache.java @@ -0,0 +1,33 @@ +package com.github.imdmk.playtime.feature.playtime.top; + +import com.github.imdmk.playtime.feature.playtime.PlayTimeUser; +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import org.jetbrains.annotations.NotNull; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +@Service(priority = ComponentPriority.LOW) +final class PlayTimeTopCache { + + private final AtomicReference snapshot = new AtomicReference<>(); + + Optional get() { + return Optional.ofNullable(snapshot.get()); + } + + void update(@NotNull List users) { + snapshot.set(new CachedPlayTimeTop( + List.copyOf(users), + Instant.now() + )); + } + + void invalidate() { + snapshot.set(null); + } +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopCommand.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopCommand.java new file mode 100644 index 0000000..0fbe592 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopCommand.java @@ -0,0 +1,49 @@ +package com.github.imdmk.playtime.feature.playtime.top; + +import com.github.imdmk.playtime.feature.playtime.top.gui.PlayTimeTopGui; +import com.github.imdmk.playtime.injector.annotations.lite.LiteCommand; +import com.github.imdmk.playtime.platform.gui.view.GuiOpener; +import com.github.imdmk.playtime.platform.logger.PluginLogger; +import com.github.imdmk.playtime.shared.message.MessageService; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Context; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +@LiteCommand +@Command(name = "playtime top") +@Permission("command.playtime.top") +final class PlayTimeTopCommand { + + private final PluginLogger logger; + private final MessageService messageService; + private final GuiOpener guiOpener; + private final PlayTimeTopService topService; + + @Inject + PlayTimeTopCommand( + @NotNull PluginLogger logger, + @NotNull MessageService messageService, + @NotNull GuiOpener guiOpener, + @NotNull PlayTimeTopService topService + ) { + this.logger = logger; + this.messageService = messageService; + this.guiOpener = guiOpener; + this.topService = topService; + } + + @Execute + void openGui(@Context Player viewer) { + topService.getTop() + .thenAccept(topUsers -> guiOpener.open(PlayTimeTopGui.class, viewer, topUsers)) + .exceptionally(e -> { + messageService.send(viewer, notice -> notice.actionExecutionError); + logger.error(e, "Failed to open top users"); + return null; + }); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopConfig.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopConfig.java new file mode 100644 index 0000000..d2efdf7 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopConfig.java @@ -0,0 +1,71 @@ +package com.github.imdmk.playtime.feature.playtime.top; + +import com.github.imdmk.playtime.config.ConfigSection; +import com.github.imdmk.playtime.injector.annotations.ConfigFile; +import eu.okaeri.configs.annotation.Comment; +import eu.okaeri.configs.serdes.OkaeriSerdesPack; + +import java.time.Duration; + +@ConfigFile +public final class PlayTimeTopConfig extends ConfigSection { + + @Comment({ + "#", + "# How many users are fetched from the database when building the leaderboard snapshot.", + "#", + "# This affects DATABASE LOAD only.", + "# The fetched list is cached and reused for GUI, commands and pagination.", + "#", + "# Recommended:", + "# - Small servers: 20–30", + "# - Medium servers: 30–50", + "# - Large servers: 50–100", + "#" + }) + public int topUsersQueryLimit = 50; + + @Comment({ + "#", + "# How many users are displayed in the /playtime top GUI.", + "#", + "# This does NOT affect database queries.", + "# It only slices the cached leaderboard snapshot.", + "#" + }) + public int topUsersGuiLimit = 10; + + @Comment({ + "#", + "# How long the cached leaderboard snapshot stays valid.", + "#", + "# After this time expires, the next request will reload data from the database.", + "#", + "# Recommended:", + "# - 5–15 minutes", + "#" + }) + public Duration topUsersCacheExpireAfter = Duration.ofMinutes(10); + + @Comment({ + "#", + "# Maximum time allowed for the database query that loads the leaderboard.", + "#", + "# If exceeded, the operation fails instead of blocking the server.", + "#", + "# Recommended:", + "# - 2–5 seconds", + "#" + }) + public Duration topUsersQueryTimeout = Duration.ofSeconds(3); + + @Override + public OkaeriSerdesPack serdesPack() { + return registry -> {}; + } + + @Override + public String fileName() { + return "playtime-top.yml"; + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopService.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopService.java new file mode 100644 index 0000000..37755be --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/PlayTimeTopService.java @@ -0,0 +1,68 @@ +package com.github.imdmk.playtime.feature.playtime.top; + +import com.github.imdmk.playtime.feature.playtime.PlayTimeUser; +import com.github.imdmk.playtime.feature.playtime.PlayTimeUserRepository; +import com.github.imdmk.playtime.injector.annotations.Service; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +@Service +final class PlayTimeTopService { + + private final PlayTimeTopCache cache; + private final PlayTimeTopConfig config; + private final PlayTimeUserRepository repository; + + @Inject + PlayTimeTopService( + @NotNull PlayTimeTopCache cache, + @NotNull PlayTimeTopConfig config, + @NotNull PlayTimeUserRepository repository + ) { + this.cache = cache; + this.config = config; + this.repository = repository; + } + + CompletableFuture> getTop() { + return getTopForDisplay(config.topUsersGuiLimit); + } + + CompletableFuture> getTopForDisplay(int displayLimit) { + if (displayLimit <= 0) { + return CompletableFuture.completedFuture(List.of()); + } + + final Instant now = Instant.now(); + + final Optional cached = cache.get(); + if (cached.isPresent() && !cached.get().isExpired(config.topUsersCacheExpireAfter, now)) { + return CompletableFuture.completedFuture( + slice(cached.get().users(), displayLimit) + ); + } + + return repository.findTopByPlayTime(config.topUsersQueryLimit) + .orTimeout(config.topUsersQueryTimeout.toMillis(), TimeUnit.MILLISECONDS) + .thenApply(users -> { + cache.update(users); + return slice(users, displayLimit); + }); + } + + void invalidateCache() { + cache.invalidate(); + } + + private static List slice(List users, int limit) { + return users.size() <= limit ? users : users.subList(0, limit); + } +} + + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/gui/PlayTimeTopGui.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/gui/PlayTimeTopGui.java new file mode 100644 index 0000000..f0c4fa0 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/gui/PlayTimeTopGui.java @@ -0,0 +1,115 @@ +package com.github.imdmk.playtime.feature.playtime.top.gui; + +import com.github.imdmk.playtime.feature.playtime.PlayTimeUser; +import com.github.imdmk.playtime.injector.annotations.Gui; +import com.github.imdmk.playtime.platform.adventure.AdventureFormatter; +import com.github.imdmk.playtime.platform.adventure.AdventurePlaceholders; +import com.github.imdmk.playtime.platform.gui.GuiType; +import com.github.imdmk.playtime.platform.gui.config.NavigationBarConfig; +import com.github.imdmk.playtime.platform.gui.factory.GuiFactory; +import com.github.imdmk.playtime.platform.gui.item.ItemGui; +import com.github.imdmk.playtime.platform.gui.item.ItemGuiTransformer; +import com.github.imdmk.playtime.platform.gui.render.GuiRenderer; +import com.github.imdmk.playtime.platform.gui.render.RenderContext; +import com.github.imdmk.playtime.platform.gui.render.RenderOptions; +import com.github.imdmk.playtime.platform.gui.render.TriumphGuiRenderer; +import com.github.imdmk.playtime.platform.gui.view.AbstractGui; +import com.github.imdmk.playtime.platform.gui.view.ParameterizedGui; +import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; +import com.github.imdmk.playtime.shared.time.Durations; +import dev.triumphteam.gui.builder.item.BaseItemBuilder; +import dev.triumphteam.gui.builder.item.SkullBuilder; +import dev.triumphteam.gui.guis.BaseGui; +import dev.triumphteam.gui.guis.GuiItem; +import org.bukkit.OfflinePlayer; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.util.List; +import java.util.function.Consumer; + +@Gui +public final class PlayTimeTopGui + extends AbstractGui + implements ParameterizedGui> { + + private static final String GUI_ID = "playtime-top"; + + private static final GuiRenderer RENDERER = TriumphGuiRenderer.newRenderer(); + private static final RenderOptions RENDER_OPTIONS = RenderOptions.defaultHide(); + + private final Server server; + private final PlayTimeTopGuiConfig guiConfig; + + @Inject + PlayTimeTopGui( + @NotNull Server server, + @NotNull PlayTimeTopGuiConfig guiConfig, + @NotNull NavigationBarConfig config, + @NotNull TaskScheduler taskScheduler + ) { + super(config, taskScheduler, RENDERER, RENDER_OPTIONS); + this.server = server; + this.guiConfig = guiConfig; + } + + @Override + public BaseGui createGui(@NotNull Player viewer, @NotNull List topUsers) { + return GuiFactory.build(guiConfig, BaseGui::disableAllInteractions); + } + + @Override + public void prepareItems(@NotNull BaseGui gui, @NotNull Player viewer, @NotNull List topUsers) { + if (guiConfig.fillBorder) { + final GuiItem borderItem = ItemGuiTransformer.toGuiItem(guiConfig.borderItem); + gui.getFiller().fillBorder(borderItem); + } + + placeExit(gui, viewer, exit -> gui.close(viewer)); + + if (guiConfig.type == GuiType.PAGINATED) { + placeNext(gui, viewer); + placePrevious(gui, viewer); + } + + final RenderContext context = RenderContext.defaultContext(viewer); + final ItemGui item = guiConfig.playerEntryItem; + + for (int i = 0; i < topUsers.size(); i++) { + final int position = i + 1; + + final PlayTimeUser user = topUsers.get(i); + final OfflinePlayer player = server.getOfflinePlayer(user.getUuid()); + + final AdventurePlaceholders placeholders = createPlaceholders(player, user, position); + final Consumer clickHandler = (event -> event.setCancelled(true)); + + final Consumer> editor = (builder) -> { + if (builder instanceof SkullBuilder skullBuilder) { + skullBuilder.owner(player); + } + + builder.name(AdventureFormatter.format(item.name(), placeholders)); + builder.lore(AdventureFormatter.format(item.lore(), placeholders)); + }; + + renderer.addItem(gui, item, context, renderOptions, clickHandler, editor); + } + } + + private AdventurePlaceholders createPlaceholders(OfflinePlayer offlinePlayer, PlayTimeUser user, int position) { + return AdventurePlaceholders.builder() + .with("{PLAYER_NAME}", offlinePlayer.getName() == null ? "Unknown" : offlinePlayer.getName()) + .with("{PLAYER_POSITION}", position) + .with("{PLAYER_PLAYTIME}", Durations.format(user.getPlayTime().toDuration())) + .build(); + } + + @Override + public String getId() { + return GUI_ID; + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/gui/PlayTimeTopGuiConfig.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/gui/PlayTimeTopGuiConfig.java similarity index 60% rename from playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/gui/PlayTimeTopGuiConfig.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/gui/PlayTimeTopGuiConfig.java index fd0bab0..efbf111 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/gui/PlayTimeTopGuiConfig.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/playtime/top/gui/PlayTimeTopGuiConfig.java @@ -1,20 +1,26 @@ -package com.github.imdmk.playtime.feature.playtime.gui; +package com.github.imdmk.playtime.feature.playtime.top.gui; +import com.github.imdmk.playtime.config.ConfigSection; +import com.github.imdmk.playtime.injector.annotations.ConfigFile; +import com.github.imdmk.playtime.platform.adventure.AdventureComponentSerializer; import com.github.imdmk.playtime.platform.adventure.AdventureComponents; import com.github.imdmk.playtime.platform.gui.GuiType; import com.github.imdmk.playtime.platform.gui.config.ConfigurableGui; import com.github.imdmk.playtime.platform.gui.item.ItemGui; -import eu.okaeri.configs.OkaeriConfig; +import com.github.imdmk.playtime.platform.gui.item.ItemGuiSerializer; +import com.github.imdmk.playtime.platform.serdes.EnchantmentSerializer; +import com.github.imdmk.playtime.platform.serdes.SoundSerializer; import eu.okaeri.configs.annotation.Comment; +import eu.okaeri.configs.serdes.OkaeriSerdesPack; import net.kyori.adventure.text.Component; import org.bukkit.Material; -import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.ItemFlag; -import org.jetbrains.annotations.NotNull; import java.util.Collections; -public final class PlayTimeTopGuiConfig extends OkaeriConfig implements ConfigurableGui { +@ConfigFile +public final class PlayTimeTopGuiConfig extends ConfigSection + implements ConfigurableGui { @Comment({ "#", @@ -82,51 +88,13 @@ public final class PlayTimeTopGuiConfig extends OkaeriConfig implements Configur .addFlags(ItemFlag.HIDE_ATTRIBUTES) .build(); - @Comment({ - "#", - "# Base item for a single player entry in the top playtime list (admin view).", - "# Used when the viewer has management permission and can reset playtime.", - "#", - "# Placeholders:", - "# {PLAYER_POSITION} - numeric position on the leaderboard (1, 2, 3, ...).", - "# {PLAYER_NAME} - player nickname.", - "# {PLAYER_PLAYTIME} - formatted playtime (e.g. 5h 32m).", - "# {CLICK_RESET} - name/description of the click used to reset (e.g. SHIFT + Right Click).", - "#", - "# requiredPermission in item defines to see the admin version of the item.", - "#" - }) - public ItemGui playerEntryAdminItem = ItemGui.builder() - .material(Material.PLAYER_HEAD) - .name(AdventureComponents.withoutItalics( - "#{PLAYER_POSITION} - {PLAYER_NAME}" - )) - .lore(AdventureComponents.withoutItalics( - " ", - "Playtime: {PLAYER_PLAYTIME}", - " ", - "Click {CLICK_RESET} to reset {PLAYER_NAME}'s playtime." - )) - .addFlags(ItemFlag.HIDE_ATTRIBUTES) - .requiredPermission("playtime.user.manage") - .build(); - - @Comment({ - "#", - "# Click type used to trigger playtime reset in the admin view.", - "# Must match how {CLICK_RESET} is described in messages/lore.", - "# Example: SHIFT_RIGHT, SHIFT_LEFT, RIGHT, LEFT, etc.", - "#" - }) - public ClickType resetClickType = ClickType.SHIFT_RIGHT; - @Override - public @NotNull Component title() { + public Component title() { return title; } @Override - public @NotNull GuiType type() { + public GuiType type() { return type; } @@ -135,4 +103,18 @@ public int rows() { return rows; } -} + @Override + public OkaeriSerdesPack serdesPack() { + return registry -> { + registry.register(new AdventureComponentSerializer()); + registry.register(new ItemGuiSerializer()); + registry.register(new EnchantmentSerializer()); + registry.register(new SoundSerializer()); + }; + } + + @Override + public String fileName() { + return "topGuiConfig.yml"; + } +} \ No newline at end of file diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/reload/ReloadCommand.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/reload/ReloadCommand.java index 8819020..0085e44 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/reload/ReloadCommand.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/reload/ReloadCommand.java @@ -1,48 +1,49 @@ package com.github.imdmk.playtime.feature.reload; -import com.github.imdmk.playtime.config.ConfigManager; -import com.github.imdmk.playtime.message.MessageService; +import com.github.imdmk.playtime.config.ConfigAccessException; +import com.github.imdmk.playtime.config.ConfigService; +import com.github.imdmk.playtime.injector.annotations.lite.LiteCommand; import com.github.imdmk.playtime.platform.logger.PluginLogger; import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; -import com.github.imdmk.playtime.shared.validate.Validator; +import com.github.imdmk.playtime.shared.message.MessageService; import dev.rollczi.litecommands.annotations.command.Command; import dev.rollczi.litecommands.annotations.context.Context; import dev.rollczi.litecommands.annotations.execute.Execute; import dev.rollczi.litecommands.annotations.permission.Permission; -import eu.okaeri.configs.exception.OkaeriException; import org.bukkit.command.CommandSender; import org.jetbrains.annotations.NotNull; import org.panda_lang.utilities.inject.annotations.Inject; +@LiteCommand @Command(name = "playtime reload") @Permission("command.playtime.reload") -public final class ReloadCommand { +final class ReloadCommand { private final PluginLogger logger; - private final ConfigManager configManager; + private final ConfigService configService; private final TaskScheduler taskScheduler; private final MessageService messageService; @Inject - public ReloadCommand( + ReloadCommand( @NotNull PluginLogger logger, - @NotNull ConfigManager configManager, + @NotNull ConfigService configService, @NotNull TaskScheduler taskScheduler, @NotNull MessageService messageService ) { - this.logger = Validator.notNull(logger, "logger"); - this.configManager = Validator.notNull(configManager, "configManager"); - this.taskScheduler = Validator.notNull(taskScheduler, "taskScheduler"); - this.messageService = Validator.notNull(messageService, "messageService"); + this.logger = logger; + this.configService = configService; + this.taskScheduler = taskScheduler; + this.messageService = messageService; } @Execute void reload(@Context CommandSender sender) { taskScheduler.runAsync(() -> { try { - configManager.loadAll(); + configService.loadAll(); messageService.send(sender, n -> n.reloadMessages.configReloadedSuccess()); - } catch (OkaeriException e) { + } catch (ConfigAccessException e) { logger.error(e, "Failed to reload plugin configuration files"); messageService.send(sender, n -> n.reloadMessages.configReloadFailed()); } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/reload/ReloadModule.java b/playtime-core/src/main/java/com/github/imdmk/playtime/feature/reload/ReloadModule.java deleted file mode 100644 index d3b6dbc..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/feature/reload/ReloadModule.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.imdmk.playtime.feature.reload; - -import com.github.imdmk.playtime.infrastructure.module.Module; -import com.github.imdmk.playtime.infrastructure.module.phase.CommandPhase; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.Injector; -import org.panda_lang.utilities.inject.Resources; - -public final class ReloadModule implements Module { - - @Override - public void bind(@NotNull Resources resources) {} - - @Override - public void init(@NotNull Injector injector) {} - - @Override - public CommandPhase commands(@NotNull Injector injector) { - return builder -> builder.commands(injector.newInstance(ReloadCommand.class)); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseConnector.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseConnector.java deleted file mode 100644 index 5da685e..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseConnector.java +++ /dev/null @@ -1,224 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database; - -import com.github.imdmk.playtime.infrastructure.database.driver.configurer.DriverConfigurer; -import com.github.imdmk.playtime.infrastructure.database.driver.configurer.DriverConfigurerFactory; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.j256.ormlite.jdbc.DataSourceConnectionSource; -import com.j256.ormlite.support.ConnectionSource; -import com.zaxxer.hikari.HikariDataSource; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.File; -import java.sql.SQLException; -import java.sql.SQLFeatureNotSupportedException; -import java.util.logging.Level; - -final class DatabaseConnector { - - private static final String POOL_NAME = "playtime-db-pool"; - - private static final int DEFAULT_MAX_POOL_SIZE = 4; - private static final int DEFAULT_MIN_IDLE = 0; - - private static final long DEFAULT_CONNECTION_TIMEOUT_MS = 10_000L; - private static final long DEFAULT_IDLE_TIMEOUT_MS = 60_000L; - private static final long DEFAULT_MAX_LIFETIME_MS = 600_000L; - - private static final boolean CACHE_PREP_STMTS = true; - private static final int PREP_STMT_CACHE_SIZE = 250; - private static final int PREP_STMT_CACHE_SQL_LIMIT = 2048; - private static final boolean USE_SERVER_PREP_STMTS = true; - - private static final Level DATA_SOURCE_LOG_LEVEL = Level.SEVERE; - - private final PluginLogger logger; - private final DatabaseConfig config; - private final DriverConfigurer driverConfigurer; - - private volatile HikariDataSource dataSource; - private volatile ConnectionSource connectionSource; - - /** - * Creates a new connector with an explicit {@link DriverConfigurer}. - * Useful for testing or advanced customization. - * - * @param logger the plugin logger (never null) - * @param config the database configuration (never null) - * @param driverConfigurer strategy used to configure the underlying JDBC driver (never null) - */ - DatabaseConnector( - @NotNull PluginLogger logger, - @NotNull DatabaseConfig config, - @NotNull DriverConfigurer driverConfigurer - ) { - this.logger = Validator.notNull(logger, "logger cannot be null"); - this.config = Validator.notNull(config, "config cannot be null"); - this.driverConfigurer = Validator.notNull(driverConfigurer, "driverConfigurer cannot be null"); - } - - /** - * Creates a new connector using the default {@link DriverConfigurer} - * resolved from {@link DriverConfigurerFactory} based on {@link DatabaseConfig#databaseMode}. - * - * @param logger the plugin logger (never null) - * @param config the database configuration (never null) - */ - DatabaseConnector( - @NotNull PluginLogger logger, - @NotNull DatabaseConfig config - ) { - this(logger, config, DriverConfigurerFactory.getFor(config.databaseMode)); - } - - /** - * Establishes a new database connection and initializes the internal Hikari connection pool. - *

- * If already connected, this method throws {@link IllegalStateException}. - * Engine-specific configuration (JDBC URL, file paths, flags) is delegated - * to the configured {@link DriverConfigurer}. - * - * @param dataFolder plugin data folder, used especially for file-based databases (e.g. SQLite/H2) - * @throws SQLException if JDBC or ORMLite initialization fails - * @throws IllegalStateException if a connection is already active - */ - synchronized void connect(@NotNull File dataFolder) throws SQLException { - Validator.notNull(dataFolder, "dataFolder cannot be null"); - - if (dataSource != null || connectionSource != null) { - throw new IllegalStateException("DatabaseConnector is already connected."); - } - - final HikariDataSource ds = createHikariDataSource(); - - try { - // Delegated engine-specific configuration (JDBC URL, engine flags, filesystem prep) - driverConfigurer.configure(ds, config, dataFolder); - - final String jdbcUrl = ds.getJdbcUrl(); - if (jdbcUrl == null || jdbcUrl.isBlank()) { - throw new IllegalStateException("DriverConfigurer did not set JDBC URL for mode " + config.databaseMode); - } - - final ConnectionSource source = new DataSourceConnectionSource(ds, jdbcUrl); - - dataSource = ds; - connectionSource = source; - - logger.info("Connected to %s database.", config.databaseMode); - } catch (SQLException e) { - logger.error(e, "Failed to connect to database"); - closeQuietly(ds); - dataSource = null; - connectionSource = null; - throw e; - } catch (Exception e) { - logger.error(e, "Failed to initialize database"); - closeQuietly(ds); - dataSource = null; - connectionSource = null; - throw new IllegalStateException("Database initialization failed", e); - } - } - - /** - * Closes the active database connection and shuts down the underlying HikariCP pool. - *

- * Safe to call multiple times. Exceptions during close are logged but ignored. - */ - synchronized void close() { - if (connectionSource == null && dataSource == null) { - logger.warn("DatabaseConnector#close() called, but not connected."); - return; - } - - try { - if (connectionSource != null) { - connectionSource.close(); - } - } catch (Exception e) { - logger.error(e, "Failed to close ConnectionSource"); - } - - closeQuietly(dataSource); - - connectionSource = null; - dataSource = null; - - logger.info("Database connection closed successfully."); - } - - /** - * Returns whether this connector is currently connected. - * - * @return {@code true} if both {@link ConnectionSource} and {@link HikariDataSource} are active - */ - boolean isConnected() { - final HikariDataSource ds = dataSource; - return connectionSource != null && ds != null && !ds.isClosed(); - } - - /** - * Returns the current active {@link ConnectionSource}, or {@code null} if not connected. - * - * @return active ORMLite connection source, or {@code null} if disconnected - */ - @Nullable ConnectionSource getConnectionSource() { - return connectionSource; - } - - /** - * Creates and configures a new {@link HikariDataSource} with conservative, engine-agnostic defaults. - *

- * Includes: - *

    - *
  • Moderate pool sizing (max 5, min 1 by default).
  • - *
  • Prepared statement caching (effective for MySQL-family drivers, harmless elsewhere).
  • - *
  • Connection, idle and lifetime timeouts with safe values.
  • - *
- * - * @return configured Hikari data source (not yet started) - */ - private @NotNull HikariDataSource createHikariDataSource() { - final HikariDataSource data = new HikariDataSource(); - data.setPoolName(POOL_NAME); - - data.setMaximumPoolSize(Math.max(DEFAULT_MAX_POOL_SIZE, Runtime.getRuntime().availableProcessors())); - data.setMinimumIdle(DEFAULT_MIN_IDLE); - - data.setUsername(config.databaseUserName); - data.setPassword(config.databasePassword); - - // Reduce noisy driver logging if supported - try { - data.getParentLogger().setLevel(DATA_SOURCE_LOG_LEVEL); - } catch (SQLFeatureNotSupportedException ignored) {} - - // Prepared statement cache (useful for MySQL-family; harmless for others) - data.addDataSourceProperty("cachePrepStmts", CACHE_PREP_STMTS); - data.addDataSourceProperty("prepStmtCacheSize", PREP_STMT_CACHE_SIZE); - data.addDataSourceProperty("prepStmtCacheSqlLimit", PREP_STMT_CACHE_SQL_LIMIT); - data.addDataSourceProperty("useServerPrepStmts", USE_SERVER_PREP_STMTS); - - // Timeout configuration (milliseconds) - data.setConnectionTimeout(DEFAULT_CONNECTION_TIMEOUT_MS); - data.setIdleTimeout(DEFAULT_IDLE_TIMEOUT_MS); - data.setMaxLifetime(DEFAULT_MAX_LIFETIME_MS); - - return data; - } - - /** - * Closes the given {@link HikariDataSource} without propagating exceptions. - * - * @param ds data source to close (nullable) - */ - private static void closeQuietly(@Nullable HikariDataSource ds) { - try { - if (ds != null) { - ds.close(); - } - } catch (Exception ignored) {} - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseManager.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseManager.java deleted file mode 100644 index 46d50e8..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseManager.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database; - -import com.github.imdmk.playtime.infrastructure.database.driver.dependency.DriverDependencyLoader; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.j256.ormlite.support.ConnectionSource; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.sql.SQLException; - -public final class DatabaseManager { - - private final Plugin plugin; - private final DatabaseConfig config; - - private final DriverDependencyLoader driverLoader; - private final DatabaseConnector connector; - - public DatabaseManager( - @NotNull Plugin plugin, - @NotNull PluginLogger logger, - @NotNull DatabaseConfig config - ) { - this.plugin = Validator.notNull(plugin, "plugin"); - this.config = Validator.notNull(config, "config"); - - this.driverLoader = new DriverDependencyLoader(plugin); - this.connector = new DatabaseConnector(logger, config); - } - - public void loadDriver() { - driverLoader.loadDriverFor(config.databaseMode); - } - - public void connect() throws SQLException { - connector.connect(plugin.getDataFolder()); - } - - @Nullable - public ConnectionSource getConnection() { - return connector.getConnectionSource(); - } - - public void shutdown() { - if (connector.isConnected()) { - connector.close(); - } - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseMode.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseMode.java deleted file mode 100644 index 940faa7..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/DatabaseMode.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database; - -/** - * Enumerates all database engines supported by the plugin. - *

- * Each value represents a distinct JDBC provider and is used to: - *

    - *
  • select and load the correct JDBC driver dynamically,
  • - *
  • apply engine-specific JDBC URL configuration,
  • - *
  • initialize the matching {@code DriverConfigurer} implementation.
  • - *
- *

- * Below each engine is annotated with practical recommendations - * for typical Minecraft server environments. - */ -public enum DatabaseMode { - - /** - * MySQL — recommended for most production servers. - *

- * Stable, well-supported, widely hosted, good performance under sustained load. - * Best choice for: medium–large servers, networks, Bungee/Velocity setups. - */ - MYSQL, - - /** - * MariaDB — drop-in MySQL replacement. - *

- * Often faster for reads, lighter resource usage, very stable on Linux hosts. - * Best choice for: self-hosted servers (VPS/dedicated), users preferring open-source MySQL alternatives. - */ - MARIADB, - - /** - * SQLite — file-based embedded database. - *

- * Zero configuration, no external server needed, safe for smaller datasets. - * Best choice for: small servers, testing environments, local development. - * Avoid it for large playtime tables or heavy concurrent write load. - */ - SQLITE, - - /** - * PostgreSQL — robust, enterprise-grade server engine. - *

- * Very strong consistency guarantees, excellent indexing, powerful features. - * Best choice for: large datasets, advanced analytics, servers on modern hosting (e.g., managed PSQL). - */ - POSTGRESQL, - - /** - * H2 — lightweight embedded or file-based engine. - *

- * Faster than SQLite in many scenarios, supports MySQL compatibility mode. - * Best choice for: plugin developers, embedded deployments, users wanting higher performance without external DB. - * Not recommended for: huge datasets or multi-server networks. - */ - H2, - - /** - * SQL Server (MSSQL) — enterprise Microsoft database engine. - *

- * Works well on Windows hosts, strong enterprise tooling. - * Best choice for: Windows-based servers, corporate networks using MSSQL by default. - * Rarely needed for typical Minecraft environments. - */ - SQL -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/DriverConfigurer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/DriverConfigurer.java deleted file mode 100644 index 84621cf..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/DriverConfigurer.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database.driver.configurer; - -import com.github.imdmk.playtime.infrastructure.database.DatabaseConfig; -import com.zaxxer.hikari.HikariDataSource; -import org.jetbrains.annotations.NotNull; - -import java.io.File; - -/** - * Strategy interface defining how to configure a {@link HikariDataSource} - * for a specific database engine. - *

- * Implementations are responsible for: - *

    - *
  • constructing the correct JDBC URL,
  • - *
  • applying engine-specific HikariCP properties,
  • - *
  • performing any required filesystem preparation (e.g. SQLite/H2 directories).
  • - *
- * This abstraction allows {@link com.github.imdmk.playtime.infrastructure.database.DatabaseConnector} - * to remain engine-agnostic while still supporting multiple database types. - */ -public interface DriverConfigurer { - - /** - * Configures the provided {@link HikariDataSource} instance using the database - * settings supplied in {@link DatabaseConfig} and the plugin data folder. - *

- * Implementations must be deterministic and side-effect-free except for: - *

    - *
  • modifying the {@code dataSource} instance,
  • - *
  • creating required directories for file-based databases.
  • - *
- * - * @param dataSource the HikariCP data source to configure (never null) - * @param config the database configuration containing connection details (never null) - * @param dataFolder the plugin data folder, used especially for file-based engines like SQLite/H2 (never null) - */ - void configure(@NotNull HikariDataSource dataSource, - @NotNull DatabaseConfig config, - @NotNull File dataFolder); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/DriverConfigurerFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/DriverConfigurerFactory.java deleted file mode 100644 index 4085841..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/configurer/DriverConfigurerFactory.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database.driver.configurer; - -import com.github.imdmk.playtime.infrastructure.database.DatabaseMode; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.jetbrains.annotations.NotNull; - -import java.util.Map; - -/** - * Factory responsible for selecting the correct {@link DriverConfigurer} - * implementation for a given {@link DatabaseMode}. - *

- * All supported drivers are registered statically in an immutable lookup table. - * This ensures fast resolution, avoids reflection, and cleanly separates - * database-specific logic into dedicated strategy classes. - *

- * The factory acts as the single entry point for retrieving driver configuration - * strategies used by {@code DatabaseConnector}. - */ -public final class DriverConfigurerFactory { - - /** Immutable lookup table mapping database modes to their respective configurers. */ - private static final Map CONFIGURER_BY_MODE = Map.of( - DatabaseMode.MYSQL, new MySQLConfigurer(), - DatabaseMode.MARIADB, new MariaDBConfigurer(), - DatabaseMode.POSTGRESQL, new PostgreSQLConfigurer(), - DatabaseMode.SQLITE, new SQLiteConfigurer(), - DatabaseMode.H2, new H2Configurer(), - DatabaseMode.SQL, new SQLConfigurer() - ); - - /** - * Returns the {@link DriverConfigurer} associated with the given {@link DatabaseMode}. - * - * @param mode the selected database engine (never null) - * @return the matching non-null {@link DriverConfigurer} - * @throws IllegalArgumentException if the mode is not supported - */ - public static @NotNull DriverConfigurer getFor(@NotNull DatabaseMode mode) { - Validator.notNull(mode, "mode cannot be null"); - - DriverConfigurer configurer = CONFIGURER_BY_MODE.get(mode); - if (configurer == null) { - throw new IllegalArgumentException("Unsupported database mode: " + mode); - } - - return configurer; - } - - private DriverConfigurerFactory() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/dependency/DriverDependencyLoader.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/dependency/DriverDependencyLoader.java deleted file mode 100644 index f10cf1a..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/driver/dependency/DriverDependencyLoader.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database.driver.dependency; - -import com.alessiodp.libby.BukkitLibraryManager; -import com.alessiodp.libby.Library; -import com.github.imdmk.playtime.infrastructure.database.DatabaseMode; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.NotNull; - -import java.util.Map; - -/** - * Loads JDBC driver libraries dynamically at runtime using Libby. - *

- * Each {@link DatabaseMode} is mapped to a specific third-party JDBC driver - * defined in {@link DriverLibraries}. This allows the plugin to ship without - * any embedded JDBC drivers and load only the required one on demand. - *

- * This component is deliberately isolated from connection logic to keep the - * database layer modular and compliant with SRP (single responsibility). - */ -public final class DriverDependencyLoader { - - /** Immutable lookup table mapping supported database modes to driver artifacts. */ - private static final Map LIBRARIES_BY_MODE = Map.of( - DatabaseMode.MYSQL, DriverLibraries.MYSQL, - DatabaseMode.MARIADB, DriverLibraries.MARIADB, - DatabaseMode.SQLITE, DriverLibraries.SQLITE, - DatabaseMode.POSTGRESQL, DriverLibraries.POSTGRESQL, - DatabaseMode.H2, DriverLibraries.H2, - DatabaseMode.SQL, DriverLibraries.SQL - ); - - private final BukkitLibraryManager libraryManager; - - /** - * Creates a new dependency loader using a pre-initialized {@link BukkitLibraryManager}. - * Maven Central is automatically added as the default repository source. - * - * @param libraryManager the library manager used to load driver JARs dynamically - */ - public DriverDependencyLoader(@NotNull BukkitLibraryManager libraryManager) { - this.libraryManager = Validator.notNull(libraryManager, "libraryManager cannot be null"); - this.libraryManager.addMavenCentral(); - } - - /** - * Convenience constructor that initializes a {@link BukkitLibraryManager} using the plugin instance. - * - * @param plugin the owning plugin instance - */ - public DriverDependencyLoader(@NotNull Plugin plugin) { - this(new BukkitLibraryManager(plugin)); - } - - /** - * Loads the JDBC driver dependency associated with the given {@link DatabaseMode}. - *

- * If the driver is already loaded, Libby will skip re-loading it automatically. - * - * @param mode the database mode requesting its driver (never null) - * @throws IllegalArgumentException if the mode has no registered driver - */ - public void loadDriverFor(@NotNull DatabaseMode mode) { - Validator.notNull(mode, "mode cannot be null"); - - Library library = LIBRARIES_BY_MODE.get(mode); - if (library == null) { - throw new IllegalArgumentException("Unsupported database mode: " + mode); - } - - libraryManager.loadLibrary(library); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/Repository.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/Repository.java deleted file mode 100644 index 97327d7..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/Repository.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database.repository; - -import com.j256.ormlite.support.ConnectionSource; -import org.jetbrains.annotations.NotNull; - -import java.sql.SQLException; - -/** - * Base contract for all repositories. - *

- * Provides lifecycle hooks for database initialization and cleanup. - * Implementations should create their DAO bindings in {@link #start(ConnectionSource)} - * and release resources in {@link #close()}. - */ -public interface Repository extends AutoCloseable { - - /** - * Initializes repository to the given connection source. - * - * @param source the ORMLite connection source - * @throws SQLException if database initialization fails - */ - void start(@NotNull ConnectionSource source) throws SQLException; - - /** - * Closes the repository and releases all resources. - */ - @Override - void close(); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/RepositoryContext.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/RepositoryContext.java deleted file mode 100644 index fddcbb3..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/RepositoryContext.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database.repository; - -import com.github.imdmk.playtime.infrastructure.database.repository.ormlite.BaseDaoRepository; -import org.jetbrains.annotations.NotNull; - -import java.util.concurrent.ExecutorService; - -/** - * Context container providing shared infrastructure resources - * for all repositories. - * - *

Currently encapsulates the {@link ExecutorService} responsible for - * executing asynchronous database operations. This allows repositories - * to offload blocking I/O work while maintaining a unified execution policy.

- * - *

Usage: Injected into repository instances (see {@link BaseDaoRepository}) - * to provide consistent thread management for database access.

- * - *

Threading: The supplied {@code dbExecutor} should be a dedicated, - * bounded thread pool optimized for database I/O tasks — typically sized according - * to connection pool limits or database concurrency capabilities.

- * - * @param dbExecutor the executor service used for running asynchronous database operations (non-null) - * - * @see BaseDaoRepository - */ -public record RepositoryContext(@NotNull ExecutorService dbExecutor) { -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/RepositoryManager.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/RepositoryManager.java deleted file mode 100644 index 9258eb7..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/RepositoryManager.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database.repository; - -import com.github.imdmk.playtime.infrastructure.database.repository.ormlite.BaseDaoRepository; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.j256.ormlite.support.ConnectionSource; -import org.jetbrains.annotations.NotNull; - -import java.sql.SQLException; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; - -/** - * Central coordinator that manages the lifecycle of all registered {@link Repository} instances. - * - *

This class provides thread-safe registration, startup, and shutdown of repositories. - * It acts as the single entry point for initializing all repositories - * once the plugin’s database connection has been established.

- * - *

Thread-safety: Repository registration and iteration are backed by - * {@link CopyOnWriteArrayList}, ensuring safe concurrent reads and registrations.

- * - * @see Repository - * @see BaseDaoRepository - * @see ConnectionSource - */ -public final class RepositoryManager implements AutoCloseable { - - private final PluginLogger logger; - - private final List repositories = new CopyOnWriteArrayList<>(); - - public RepositoryManager(@NotNull PluginLogger logger) { - this.logger = Validator.notNull(logger, "logger must not be null"); - } - - /** - * Registers a single repository instance for lifecycle management. - * - *

If the repository has already been registered, the operation is skipped - * and a warning is logged.

- * - * @param repository non-null repository instance - * @throws NullPointerException if {@code repository} is null - */ - public void register(@NotNull Repository repository) { - Validator.notNull(repository, "repository cannot be null"); - if (repositories.contains(repository)) { - logger.warn("Repository %s already registered — skipping", repository.getClass().getSimpleName()); - return; - } - - repositories.add(repository); - } - - /** - * Registers multiple repository instances at once. - * - * @param repositories one or more non-null repositories - * @throws NullPointerException if {@code repositories} array or any element is null - */ - public void register(@NotNull Repository... repositories) { - Validator.notNull(repositories, "repositories cannot be null"); - for (final Repository repository : repositories) { - this.register(repository); - } - } - - /** - * Starts all registered repositories using the provided {@link ConnectionSource}. - * - *

This method creates required tables and initializes all DAO layers. - * If any repository fails to start, the exception is logged and rethrown, - * stopping further startup to prevent inconsistent state.

- * - * @param connectionSource non-null active database connection source - * @throws SQLException if a repository fails to start - * @throws NullPointerException if {@code connectionSource} is null - */ - public void startAll(@NotNull ConnectionSource connectionSource) throws SQLException { - Validator.notNull(connectionSource, "connectionSource cannot be null"); - for (final Repository repository : repositories) { - try { - repository.start(connectionSource); - } catch (SQLException e) { - logger.error(e, "Failed to start repository: %s", repository.getClass().getSimpleName()); - throw e; - } - } - } - - /** - * Gracefully closes all registered repositories. - * - *

Each repository’s {@link Repository#close()} method is invoked individually. - * Exceptions during closing are caught and logged as warnings, allowing - * all repositories to attempt shutdown even if one fails.

- */ - @Override - public void close() { - for (final Repository repository : repositories) { - try { - repository.close(); - } catch (Exception e) { - logger.warn(e, "Error while closing repository: %s", repository.getClass().getSimpleName()); - } - } - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/ormlite/BaseDaoRepository.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/ormlite/BaseDaoRepository.java deleted file mode 100644 index 065bb32..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/ormlite/BaseDaoRepository.java +++ /dev/null @@ -1,227 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database.repository.ormlite; - -import com.github.imdmk.playtime.infrastructure.database.repository.Repository; -import com.github.imdmk.playtime.infrastructure.database.repository.RepositoryContext; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.j256.ormlite.dao.Dao; -import com.j256.ormlite.dao.DaoManager; -import com.j256.ormlite.logger.Level; -import com.j256.ormlite.logger.Logger; -import com.j256.ormlite.support.ConnectionSource; -import com.j256.ormlite.table.TableUtils; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.sql.SQLException; -import java.time.Duration; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.function.Supplier; - -/** - * Base class for ORMLite-backed repositories that manages DAO lifecycle, - * schema bootstrapping, and asynchronous query execution. - * - *

Responsibilities:

- *
    - *
  • Create required tables for the main entity and optional subclasses.
  • - *
  • Initialize and expose a typed {@link Dao} instance.
  • - *
  • Provide helper methods to run DAO work asynchronously with a bounded timeout.
  • - *
- * - *

Thread-safety: the {@link #dao} reference is {@code volatile} to ensure - * visibility after initialization. Repository implementations should still avoid compound unsynchronized - * operations on the DAO.

- * - * @param entity type handled by the repository - * @param identifier type of the entity - * - * @see Dao - * @see RepositoryContext - * @see TableUtils - */ -public abstract class BaseDaoRepository implements Repository { - - private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(6); - - private final PluginLogger logger; - private final RepositoryContext context; - - protected volatile Dao dao; - - @Inject - protected BaseDaoRepository(@NotNull PluginLogger logger, @NotNull RepositoryContext context) { - this.logger = Validator.notNull(logger, "logger cannot be null"); - this.context = Validator.notNull(context, "context cannot be null"); - Logger.setGlobalLogLevel(Level.ERROR); // Change ORMLITE logging to errors - } - - protected abstract Class entityClass(); - protected abstract List> entitySubClasses(); - - /** - * Initializes the repository: creates missing tables and registers the DAO. - * - *

Tables for {@link #entityClass()} and all {@link #entitySubClasses()} are created if absent. - * Then a new {@link Dao} is obtained via {@link DaoManager#createDao(ConnectionSource, Class)}.

- * - * @param source active ORMLite connection source - * @throws SQLException if schema creation or DAO initialization fails - */ - @Override - public void start(@NotNull ConnectionSource source) throws SQLException { - for (Class subClass : this.entitySubClasses()) { - TableUtils.createTableIfNotExists(source, subClass); - } - - TableUtils.createTableIfNotExists(source, this.entityClass()); - this.dao = DaoManager.createDao(source, this.entityClass()); - } - - /** - * Closes the repository by unregistering the current DAO from its {@link ConnectionSource}. - * - *

This method is idempotent. If no DAO is set, it returns immediately.

- */ - @Override - public void close() { - final Dao current = this.dao; - if (current == null) { - return; - } - - this.dao = null; - ConnectionSource source = current.getConnectionSource(); - if (source != null) { - DaoManager.unregisterDao(source, current); - } - } - - /** - * Executes the supplied task asynchronously on the repository executor with the default timeout. - * - *

Exceptions thrown by the supplier are logged and rethrown as {@link CompletionException}. - * If the task exceeds timeout, the returned future completes exceptionally - * with a {@link TimeoutException} wrapped in a {@link CompletionException}.

- * - * @param supplier unit of work to execute (non-null) - * @param result type - * @return a future completed with the supplier result or exceptionally on failure/timeout - * @throws NullPointerException if {@code supplier} is null - */ - protected CompletableFuture executeAsync(@NotNull Supplier supplier) { - return executeAsync(supplier, DEFAULT_TIMEOUT); - } - - /** - * Executes the supplied task asynchronously on the repository executor with a custom timeout. - * - *

Behavior:

- *
    - *
  • Runs on {@code context.dbExecutor()}.
  • - *
  • Logs and wraps exceptions into {@link CompletionException}.
  • - *
  • Applies {@link CompletableFuture#orTimeout(long, TimeUnit)} with the provided duration.
  • - *
- * - * @param supplier unit of work to execute (non-null) - * @param timeout maximum execution time (non-null) - * @param result type - * @return a future completed with the supplier result or exceptionally on failure/timeout - * @throws NullPointerException if {@code supplier} or {@code timeout} is null - */ - protected CompletableFuture executeAsync(@NotNull Supplier supplier, @NotNull Duration timeout) { - Validator.notNull(supplier, "supplier cannot be null"); - Validator.notNull(timeout, "timeout cannot be null"); - - return CompletableFuture - .supplyAsync(() -> { - try { - return supplier.get(); - } catch (Exception e) { - logger.error(e, "Async DAO operation failed"); - throw new CompletionException(e); - } - }, this.context.dbExecutor()) - .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) - .exceptionally(e -> { - if (e instanceof TimeoutException) { - logger.warn("Async DAO operation timed out after %s ms", timeout.toMillis()); - } else { - logger.error(e, "Async DAO operation failed (outer)"); - } - throw (e instanceof CompletionException) - ? (CompletionException) e - : new CompletionException(e); - }); - } - - /** - * Executes the given runnable asynchronously on the repository executor with the default timeout. - * - * @param runnable task to run (non-null) - * @return a future completed normally on success or exceptionally on failure/timeout - * @throws NullPointerException if {@code runnable} is null - */ - protected CompletableFuture executeAsync(@NotNull Runnable runnable) { - return executeAsync(runnable, DEFAULT_TIMEOUT); - } - - /** - * Executes the given runnable asynchronously on the repository executor with a custom timeout. - * - *

Exceptions thrown by the runnable are logged and propagated as {@link CompletionException}. - * On timeout, the future completes exceptionally with a {@link TimeoutException} wrapped in a - * {@link CompletionException}.

- * - * @param runnable task to run (non-null) - * @param timeout maximum execution time (non-null) - * @return a future completed normally on success or exceptionally on failure/timeout - * @throws NullPointerException if {@code runnable} or {@code timeout} is null - */ - protected CompletableFuture executeAsync(@NotNull Runnable runnable, @NotNull Duration timeout) { - return CompletableFuture - .runAsync(() -> { - try { - runnable.run(); - } catch (Exception e) { - logger.error(e, "Async DAO operation failed"); - throw new CompletionException(e); - } - }, this.context.dbExecutor()) - .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) - .exceptionally(e -> { - if (e instanceof TimeoutException) { - logger.warn("Async DAO operation (void) timed out after %s ms", timeout.toMillis()); - } else { - logger.error(e, "Async DAO operation failed (void outer)"); - } - throw (e instanceof CompletionException) - ? (CompletionException) e - : new CompletionException(e); - }); - } - - /** - * Executes work requiring an initialized DAO, failing fast if the repository has not been started. - * - *

Use this to guard synchronous code paths that assume the DAO is ready.

- * - * @param work supplier executed with the current repository state (non-null) - * @param result type - * @return the supplier's result - * @throws IllegalStateException if the DAO is not initialized (e.g. {@link #start(ConnectionSource)} not called) - * @throws NullPointerException if {@code work} is null - */ - protected R withDao(@NotNull Supplier work) { - Dao current = this.dao; - if (current == null) { - throw new IllegalStateException(getClass().getSimpleName() + ": DAO not initialized. Call RepositoryManager.startAll()"); - } - - return work.get(); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/ormlite/EntityMapper.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/ormlite/EntityMapper.java deleted file mode 100644 index bac72a6..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/ormlite/EntityMapper.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database.repository.ormlite; - -import org.jetbrains.annotations.NotNull; - -import java.util.List; -import java.util.stream.Collectors; - -/** - * Defines a bidirectional mapper between persistence-layer entities (ORM objects) - * and domain-layer models (business objects). - *

- * This abstraction keeps the repository layer decoupled from the domain layer, - * allowing storage representations to evolve independently of business logic. - * - * @param entity type used for persistence (ORM/DB representation) - * @param domain model type used in business logic - */ -public interface EntityMapper { - - /** - * Maps a domain model instance into its persistence-layer entity representation. - * - * @param domain the domain object to convert (never null) - * @return the corresponding persistence entity - */ - @NotNull E toEntity(@NotNull D domain); - - /** - * Maps a persistence-layer entity into its domain model representation. - * - * @param entity the persistence entity to convert (never null) - * @return the corresponding domain model - */ - @NotNull D toDomain(@NotNull E entity); - - /** - * Converts a list of persistence entities to domain model objects. - *

- * This is a convenience method for bulk transformations. - * - * @param entities list of entities to convert (never null) - * @return list of mapped domain models - */ - default @NotNull List toDomainList(@NotNull List entities) { - return entities.stream().map(this::toDomain).collect(Collectors.toList()); - } - - /** - * Converts a list of domain model objects to persistence entities. - *

- * This is a convenience method for bulk transformations. - * - * @param domains list of domain objects to convert (never null) - * @return list of mapped persistence entities - */ - default @NotNull List toEntityList(@NotNull List domains) { - return domains.stream().map(this::toEntity).collect(Collectors.toList()); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/ormlite/EntityMeta.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/ormlite/EntityMeta.java deleted file mode 100644 index 37ce878..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/database/repository/ormlite/EntityMeta.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.database.repository.ormlite; - -/** - * Marker interface for database entity metadata containers. - * - *

All metadata interfaces (e.g. {@code UserEntityMeta}) should extend this - * interface to indicate that they define static constants describing database - * schema elements such as table and column names.

- * - *

This provides a unified contract for schema metadata used by ORMLite - * entities, repositories, and migration utilities.

- */ -public interface EntityMeta { -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/injector/Bind.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/injector/Bind.java deleted file mode 100644 index 917412d..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/injector/Bind.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.injector; - -import org.panda_lang.utilities.inject.annotations.Injectable; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Marks a field as a core - * dependency that should be automatically registered into the DI container. - *

- * Fields annotated with {@code @BindCore} are discovered by - * PlayTimeCoreBinder and exposed to - * the Panda DI {@link org.panda_lang.utilities.inject.Resources} as singleton instances. - *

- * Only non-static fields are eligible. A {@code null} value at binding time - * results in a bootstrap failure. - */ -@Injectable -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.FIELD) -public @interface Bind { -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/Module.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/Module.java deleted file mode 100644 index 879c498..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/Module.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.module; - -import com.github.imdmk.playtime.infrastructure.module.phase.CommandPhase; -import com.github.imdmk.playtime.infrastructure.module.phase.GuiPhase; -import com.github.imdmk.playtime.infrastructure.module.phase.ListenerPhase; -import com.github.imdmk.playtime.infrastructure.module.phase.PlaceholderPhase; -import com.github.imdmk.playtime.infrastructure.module.phase.RepositoryPhase; -import com.github.imdmk.playtime.infrastructure.module.phase.TaskPhase; -import org.bukkit.Server; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.Injector; -import org.panda_lang.utilities.inject.Resources; - -/** - * Base lifecycle contract for all PlayTime modules. - * - *

Lifecycle phases: - *

    - *
  1. bind(Resources) – expose and wire resources into the DI context.
  2. - *
  3. init(Injector) – initialize internal state; safe to use bound resources.
  4. - *
- * The manager guarantees {@code bind()} is called before {@code init()}.

- * - *

Threading: modules are initialized on the server main thread unless documented otherwise. - * Implementations should avoid long blocking operations in {@code bind()} and {@code init()}.

- */ -public interface Module extends ModuleOrdered { - - /** - * Binds resources into the DI container. This phase happens before {@link #init(Injector)}. - * - * @param resources DI resources registry (never {@code null}) - */ - void bind(@NotNull Resources resources); - - /** - * Initializes the module. At this point, all resources are already bound. - * - * @param injector DI injector (never {@code null}) - */ - void init(@NotNull Injector injector); - - /** - * Repositories registration phase (optional). - */ - default RepositoryPhase repositories(@NotNull Injector injector) { return repositoryManager -> {}; } - - /** - * Task scheduling phase (optional). - */ - default TaskPhase tasks(@NotNull Injector injector) { return taskScheduler -> {}; } - - /** - * Listener registration phase (optional). - */ - default ListenerPhase listeners(@NotNull Injector injector) { return listenerRegistrar -> {}; } - - /** - * Command registration phase (optional). - */ - default CommandPhase commands(@NotNull Injector injector) { return liteCommandsConfigurer -> {}; } - - /** - * Gui's registration phase (optional). - */ - default GuiPhase guis(@NotNull Injector injector) { return guiRegistry -> {}; } - - /** - * Placeholder's registration phase (optional). - */ - default PlaceholderPhase placeholders(@NotNull Injector injector) { return placeholderRegistry -> {}; } - - /** - * Final hook invoked after all registrations of this feature are complete. - * Runs on the main server thread. - */ - default void afterRegister(@NotNull Plugin plugin, @NotNull Server server, @NotNull Injector injector) {} - - /** - * Default neutral order. Lower values initialize earlier. - */ - @Override - default int order() { return 0; } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleContext.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleContext.java deleted file mode 100644 index a3202c4..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleContext.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.module; - -import com.github.imdmk.playtime.infrastructure.database.repository.RepositoryManager; -import com.github.imdmk.playtime.platform.events.BukkitListenerRegistrar; -import com.github.imdmk.playtime.platform.gui.GuiRegistry; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.platform.placeholder.adapter.PlaceholderAdapter; -import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; -import dev.rollczi.litecommands.LiteCommandsBuilder; -import org.bukkit.Server; -import org.bukkit.command.CommandSender; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -/** - * Immutable container holding all shared services exposed to {@link Module} implementations. - * - *

Acts as a central context object passed to all module lifecycle phases, providing access to: - *

    - *
  • Bukkit plugin and server environment,
  • - *
  • logging, scheduling and repository infrastructure,
  • - *
  • listener/command/GUI registrars,
  • - *
  • placeholder adapter (PlaceholderAPI-enabled or no-op).
  • - *
- * - *

This record is created once during plugin bootstrap and reused throughout the - * module initialization pipeline.

- */ -@Inject -public record ModuleContext( - @NotNull Plugin plugin, - @NotNull Server server, - @NotNull PluginLogger logger, - @NotNull TaskScheduler taskScheduler, - @NotNull RepositoryManager repositoryManager, - @NotNull BukkitListenerRegistrar listenerRegistrar, - @NotNull LiteCommandsBuilder liteCommandsBuilder, - @NotNull GuiRegistry guiRegistry, - @NotNull PlaceholderAdapter placeholderAdapter) { -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleInitializer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleInitializer.java deleted file mode 100644 index b0f7382..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleInitializer.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.module; - -import com.github.imdmk.playtime.shared.validate.Validator; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.Injector; - -import java.util.List; -import java.util.function.Consumer; - -/** - * Coordinates the entire lifecycle of all {@link Module} instances. - * - *

The initializer executes modules through a strict, ordered pipeline: - *

    - *
  1. {@link #loadAndSort(List)} – instantiation and deterministic ordering,
  2. - *
  3. {@link #bindAll()} – DI resource binding phase,
  4. - *
  5. {@link #initAll()} – internal module initialization,
  6. - *
  7. {@link #registerRepositories()} – repository descriptor registration,
  8. - *
  9. {@link #activateFeatures()} – tasks, listeners, commands, GUIs, placeholders, hooks.
  10. - *
- * - *

Each step is validated against an internal state machine to enforce correct order and avoid - * partially initialized modules. All operations run exclusively on the Bukkit main thread.

- * - *

Errors thrown by individual modules never abort the lifecycle — they are logged and the - * pipeline continues for remaining modules.

- */ -public final class ModuleInitializer { - - private final ModuleContext context; - private final ModuleRegistry registry; - private final Injector injector; - - private State state = State.NEW; - - /** - * Creates a new module initializer. - * - * @param context shared runtime services accessible to modules - * @param registry module registry used for instantiation and lookup - * @param injector dependency injection container used during load/init - */ - public ModuleInitializer( - @NotNull ModuleContext context, - @NotNull ModuleRegistry registry, - @NotNull Injector injector) { - this.context = Validator.notNull(context, "context cannot be null"); - this.registry = Validator.notNull(registry, "moduleRegistry cannot be null"); - this.injector = Validator.notNull(injector, "injector cannot be null"); - } - - /** - * Instantiates and sorts all module types. - * Must be executed first in the module lifecycle. - */ - public void loadAndSort(@NotNull List> types) { - Validator.notNull(types, "types cannot be null"); - - ensureMainThread(); - ensureState(State.NEW, "loadAndSort"); - - registry.setModuleTypes(types); - registry.instantiateAndSort(injector); - - state = State.LOADED; - } - - /** - * Executes the DI binding phase for all modules. - */ - public void bindAll() { - ensureMainThread(); - ensureState(State.LOADED, "bindAll"); - - forEachModule("bindAll", m -> m.bind(injector.getResources())); - state = State.BOUND; - } - - /** - * Invokes the initialization phase for all modules. - */ - public void initAll() { - ensureMainThread(); - ensureState(State.BOUND, "initAll"); - - forEachModule("initAll", m -> m.init(injector)); - state = State.INITIALIZED; - } - - /** - * Registers repository metadata for all modules. - * Does not perform database I/O. - */ - public void registerRepositories() { - ensureMainThread(); - ensureState(State.INITIALIZED, "registerRepositories"); - - forEachModule("registerRepositories", - m -> m.repositories(injector).register(context.repositoryManager())); - - state = State.REPOS_REGISTERED; - } - - /** - * Activates all runtime features: - * tasks, listeners, commands, GUIs, placeholders, and after-register hooks. - */ - public void activateFeatures() { - ensureMainThread(); - ensureState(State.REPOS_REGISTERED, "activateFeatures"); - - forEachModule("activateFeatures", m -> { - m.tasks(injector).schedule(context.taskScheduler()); - m.listeners(injector).register(context.listenerRegistrar()); - m.commands(injector).configure(context.liteCommandsBuilder()); - m.guis(injector).register(context.guiRegistry()); - m.placeholders(injector).register(context.placeholderAdapter()); - m.afterRegister(context.plugin(), context.server(), injector); - }); - - state = State.FEATURES_ACTIVATED; - } - - /** - * Internal helper executing a phase for each module, - * catching and logging exceptions from individual modules. - */ - private void forEachModule(@NotNull String phase, @NotNull Consumer moduleConsumer) { - for (final Module m : registry.modules()) { - try { - moduleConsumer.accept(m); - } catch (Throwable t) { - context.logger().error(t, "%s phase failed for module %s", phase, m.getClass().getName()); - } - } - } - - /** Validates the current initializer state. */ - private void ensureState(@NotNull State required, @NotNull String op) { - if (state != required) { - throw new IllegalStateException(op + " requires state " + required + ", but was " + state); - } - } - - /** Ensures execution on the Bukkit main thread. */ - private void ensureMainThread() { - if (!context.server().isPrimaryThread()) { - throw new IllegalStateException("PluginModuleInitializer must run on Bukkit main thread"); - } - } - - /** Internal lifecycle states used to validate the correct execution order. */ - private enum State { - NEW, - LOADED, - BOUND, - INITIALIZED, - REPOS_REGISTERED, - FEATURES_ACTIVATED - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleOrdered.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleOrdered.java deleted file mode 100644 index 846c1a7..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleOrdered.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.module; - -/** - * Defines a simple ordering contract for modules. - * Lower values indicate higher priority (executed earlier). - * - *

Ordering is used by the module manager to produce a deterministic - * initialization sequence. When two modules return the same value, the - * manager should apply a stable tie-breaker (e.g., class name).

- */ -public interface ModuleOrdered { - - /** - * Returns the order value of this component. - * Lower values mean earlier execution. - * - * @return the order value (may be negative) - */ - int order(); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleRegistry.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleRegistry.java deleted file mode 100644 index dff4f9b..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/ModuleRegistry.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.module; - -import com.github.imdmk.playtime.shared.validate.Validator; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.Injector; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; - -/** - * Maintains the registry of all {@link Module} classes and their instantiated, sorted instances. - *

- * This registry is responsible for: - *

    - *
  • holding the declared module types,
  • - *
  • instantiating them via dependency injection,
  • - *
  • sorting them deterministically according to {@link Module#order()}.
  • - *
- *

- * The registry itself is stateless between runs: every call to - * {@link #instantiateAndSort(Injector)} rebuilds the internal module list from the current types. - *

- * Thread-safety: This class is not thread-safe and must be accessed from the main server thread. - */ -public final class ModuleRegistry { - - /** Comparator defining deterministic module ordering: lower {@link Module#order()} first, then by class name. */ - private static final Comparator MODULE_ORDER = Comparator - .comparingInt(Module::order) - .thenComparing(m -> m.getClass().getName()); - - private List> moduleTypes = List.of(); - private List modules = List.of(); - - /** - * Replaces the current set of module types with a new, uninitialized list. - *

- * This method does not instantiate modules; call {@link #instantiateAndSort(Injector)} afterwards - * to build and sort the instances. - * - * @param types the new list of module classes (must not be null) - * @param the module type extending {@link Module} - * @throws NullPointerException if {@code types} is null - */ - public void setModuleTypes(@NotNull List> types) { - Validator.notNull(types, "types cannot be null"); - // defensive copy - moduleTypes = List.copyOf(types); - // reset instances - modules = List.of(); - } - - /** - * Instantiates all declared module classes using the provided {@link Injector} - * and sorts them deterministically by {@link Module#order()} and class name. - *

- * This operation is idempotent for the current module types; previous instances are discarded. - * - * @param injector the dependency injector used to construct module instances (never null) - * @throws NullPointerException if {@code injector} is null - */ - public void instantiateAndSort(@NotNull Injector injector) { - Validator.notNull(injector, "injector cannot be null"); - - final List created = new ArrayList<>(moduleTypes.size()); - for (Class type : moduleTypes) { - created.add(injector.newInstance(type)); - } - - created.sort(MODULE_ORDER); - modules = List.copyOf(created); - } - - /** - * Returns an immutable, deterministically sorted view of all instantiated modules. - *

- * The returned list is guaranteed to be ordered by {@link Module#order()} ascending, - * with a lexicographic tiebreaker on the class name for consistency across JVM runs. - * - * @return an unmodifiable list of module instances (never null, may be empty) - */ - public List modules() { - return Collections.unmodifiableList(modules); - } - - /** - * Clears all registered module types and instances. - *

- * After calling this method, the registry returns to its initial empty state. - */ - public void clear() { - moduleTypes = List.of(); - modules = List.of(); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/CommandPhase.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/CommandPhase.java deleted file mode 100644 index 7bb0447..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/CommandPhase.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.module.phase; - -import dev.rollczi.litecommands.LiteCommandsBuilder; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.NotNull; - -/** - * Functional phase interface responsible for command registration. - *

- * Implementations should declare and configure commands using the - * provided {@link LiteCommandsBuilder}. - */ -@FunctionalInterface -public interface CommandPhase { - - /** - * Configures and registers commands for this module. - * - * @param configurer the command configurer used to register LiteCommands commands (never {@code null}) - */ - void configure(@NotNull LiteCommandsBuilder configurer); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/GuiPhase.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/GuiPhase.java deleted file mode 100644 index 60ce01a..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/GuiPhase.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.module.phase; - -import com.github.imdmk.playtime.platform.gui.GuiRegistry; -import org.jetbrains.annotations.NotNull; - -/** - * Functional phase interface responsible for GUI registration. - *

- * Implementations should register all inventory or interface GUIs - * via the provided {@link GuiRegistry}. - */ -@FunctionalInterface -public interface GuiPhase { - - /** - * Registers all GUIs provided by this module. - * - * @param guiRegistry the GUI registry used for GUI definitions and factories (never {@code null}) - */ - void register(@NotNull GuiRegistry guiRegistry); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/ListenerPhase.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/ListenerPhase.java deleted file mode 100644 index ba7ed99..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/ListenerPhase.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.module.phase; - -import com.github.imdmk.playtime.platform.events.BukkitListenerRegistrar; -import org.jetbrains.annotations.NotNull; - -/** - * Functional phase interface responsible for event listener registration. - *

- * Implementations should register Bukkit {@link org.bukkit.event.Listener}s - * using the provided {@link BukkitListenerRegistrar}. - */ -@FunctionalInterface -public interface ListenerPhase { - - /** - * Registers all Bukkit listeners for this module. - * - * @param registrar the listener registrar used to bind Bukkit event listeners (never {@code null}) - */ - void register(@NotNull BukkitListenerRegistrar registrar); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/PlaceholderPhase.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/PlaceholderPhase.java deleted file mode 100644 index 15f50e4..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/PlaceholderPhase.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.module.phase; - -import com.github.imdmk.playtime.platform.placeholder.adapter.PlaceholderAdapter; -import org.jetbrains.annotations.NotNull; - -@FunctionalInterface -public interface PlaceholderPhase { - - void register(@NotNull PlaceholderAdapter placeholderAdapter); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/RepositoryPhase.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/RepositoryPhase.java deleted file mode 100644 index ad9ab8d..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/RepositoryPhase.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.module.phase; - -import com.github.imdmk.playtime.infrastructure.database.repository.RepositoryManager; -import org.jetbrains.annotations.NotNull; - -/** - * Functional phase interface responsible for repository registration. - *

- * Implementations should declare repository descriptors only — no database I/O - * should occur during this phase. - */ -@FunctionalInterface -public interface RepositoryPhase { - - /** - * Registers repository descriptors into the {@link RepositoryManager}. - * - * @param manager the repository manager used for descriptor registration (never {@code null}) - */ - void register(@NotNull RepositoryManager manager); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/TaskPhase.java b/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/TaskPhase.java deleted file mode 100644 index b3da145..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/infrastructure/module/phase/TaskPhase.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.imdmk.playtime.infrastructure.module.phase; - -import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; -import org.jetbrains.annotations.NotNull; - -/** - * Functional phase interface responsible for scheduling asynchronous or repeating tasks. - *

- * Implementations register all background or periodic tasks needed by a module - * through the provided {@link TaskScheduler}. - */ -@FunctionalInterface -public interface TaskPhase { - - /** - * Registers all scheduled tasks for this module. - * - * @param scheduler the task scheduler used to register Bukkit or async tasks (never {@code null}) - */ - void schedule(@NotNull TaskScheduler scheduler); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/Component.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/Component.java new file mode 100644 index 0000000..fcef0b3 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/Component.java @@ -0,0 +1,59 @@ +package com.github.imdmk.playtime.injector; + +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.Injector; + +import java.lang.annotation.Annotation; + +public final class Component { + + private final Class type; + private final A annotation; + private final ComponentPriority componentPriority; + private final int order; + + private Object instance; + + public Component( + @NotNull Class type, + @NotNull A annotation, + @NotNull ComponentPriority componentPriority, + int order + ) { + this.type = type; + this.annotation = annotation; + this.componentPriority = componentPriority; + this.order = order; + } + + public Class type() { + return type; + } + + public A annotation() { + return annotation; + } + + public ComponentPriority priority() { + return componentPriority; + } + + public int order() { + return order; + } + + public Object instance() { + return instance; + } + + public void createInstance(@NotNull Injector injector) { + if (instance == null) { + instance = injector.newInstance(type); + } + } +} + + + + + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentFactory.java new file mode 100644 index 0000000..d634024 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentFactory.java @@ -0,0 +1,40 @@ +package com.github.imdmk.playtime.injector; + +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.Annotation; + +final class ComponentFactory { + + Component create( + @NotNull Class type, + @NotNull Class annotationType + ) { + final A annotation = type.getAnnotation(annotationType); + + final ComponentPriority componentPriority = extractPriority(annotation); + final int order = extractOrder(annotation); + + return new Component<>(type, annotation, componentPriority, order); + } + + private ComponentPriority extractPriority(Annotation annotation) { + try { + return (ComponentPriority) annotation.annotationType() + .getMethod("priority") + .invoke(annotation); + } catch (Exception e) { + return ComponentPriority.NORMAL; + } + } + + private int extractOrder(Annotation annotation) { + try { + return (int) annotation.annotationType() + .getMethod("order") + .invoke(annotation); + } catch (Exception e) { + return 0; + } + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentManager.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentManager.java new file mode 100644 index 0000000..3bbe576 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentManager.java @@ -0,0 +1,111 @@ +package com.github.imdmk.playtime.injector; + +import com.github.imdmk.playtime.injector.processor.ComponentPostProcessor; +import com.github.imdmk.playtime.injector.processor.ComponentProcessor; +import com.github.imdmk.playtime.injector.processor.ComponentProcessorContext; +import com.github.imdmk.playtime.injector.processor.ProcessorContainer; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.Injector; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class ComponentManager { + + private final Injector injector; + private final ComponentScanner scanner; + private final ComponentSorter sorter; + + // annotation -> processor container + private final Map, ProcessorContainer> processors = new HashMap<>(); + private final List postProcessors = new ArrayList<>(); + private final List> components = new ArrayList<>(); + + private boolean scanned = false; + + public ComponentManager(@NotNull Injector injector, @NotNull String basePackage) { + this.injector = injector; + this.scanner = new ComponentScanner(basePackage); + this.sorter = new ComponentSorter(); + } + + public ComponentManager addProcessor(@NotNull ProcessorContainer container) { + if (scanned) { + throw new IllegalStateException("Cannot add processors after scanAll()"); + } + + final Class type = container.annotationType(); + if (processors.containsKey(type)) { + throw new IllegalStateException( + "Processor already registered for annotation: " + type.getName() + ); + } + + processors.put(type, container); + return this; + } + + public ComponentManager addProcessors(@NotNull Collection> containers) { + containers.forEach(this::addProcessor); + return this; + } + + public ComponentManager addPostProcessor(@NotNull ComponentPostProcessor postProcessor) { + postProcessors.add(postProcessor); + return this; + } + + public void scanAll() { + if (scanned) { + throw new IllegalStateException("scanAll() already called"); + } + + for (final ProcessorContainer container : processors.values()) { + components.addAll(scanner.scan(container.annotationType())); + } + + scanned = true; + } + + public void processAll() { + if (!scanned) { + throw new IllegalStateException("scanAll() must be called before processAll()"); + } + + final ComponentProcessorContext context = new ComponentProcessorContext(injector); + + sorter.sort(components); + + for (final Component component : components) { + processComponent(component, context); + } + } + + @SuppressWarnings("unchecked") + private void processComponent( + Component component, + ComponentProcessorContext context + ) { + component.createInstance(injector); + + final ProcessorContainer raw = processors.get(component.annotation().annotationType()); + if (raw != null) { + final ProcessorContainer container = (ProcessorContainer) raw; + final ComponentProcessor processor = container.processor(); + + processor.process( + component.instance(), + component.annotation(), + context + ); + } + + for (final ComponentPostProcessor post : postProcessors) { + post.postProcess(component.instance(), context); + } + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentPriority.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentPriority.java new file mode 100644 index 0000000..fe6f967 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentPriority.java @@ -0,0 +1,5 @@ +package com.github.imdmk.playtime.injector; + +public enum ComponentPriority { + LOWEST, LOW, NORMAL, HIGH, HIGHEST +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentScanner.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentScanner.java new file mode 100644 index 0000000..186c6cc --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentScanner.java @@ -0,0 +1,46 @@ +package com.github.imdmk.playtime.injector; + +import io.github.classgraph.ClassGraph; +import io.github.classgraph.ClassInfo; +import io.github.classgraph.ScanResult; +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Modifier; +import java.util.List; + +final class ComponentScanner { + + private static final String SHADED_LIBS = "com.github.imdmk.playtime.lib"; + + private final String basePackage; + private final ComponentFactory componentFactory; + + ComponentScanner(@NotNull String basePackage) { + this.basePackage = basePackage; + this.componentFactory = new ComponentFactory(); + } + + List> scan(@NotNull Class annotationType) { + try (final ScanResult scan = new ClassGraph() + .enableAllInfo() + .acceptPackages(basePackage) + .rejectPackages(SHADED_LIBS) + .scan()) { + + return scan.getClassesWithAnnotation(annotationType.getName()) + .stream() + .map(ClassInfo::loadClass) + .filter(ComponentScanner::isValidComponent) + .map(type -> componentFactory.create(type, annotationType)) + .toList(); + } + } + + private static boolean isValidComponent(Class type) { + return !type.isInterface() && !Modifier.isAbstract(type.getModifiers()); + } +} + + + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentSorter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentSorter.java new file mode 100644 index 0000000..886fd9b --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/ComponentSorter.java @@ -0,0 +1,17 @@ +package com.github.imdmk.playtime.injector; + +import org.jetbrains.annotations.NotNull; + +import java.util.Comparator; +import java.util.List; + +final class ComponentSorter { + + void sort(@NotNull List> components) { + components.sort(Comparator + .comparing((Component c) -> c.priority()) + .thenComparingInt(Component::order) + .thenComparing(c -> c.getClass().getName()) + ); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/ConfigFile.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/ConfigFile.java new file mode 100644 index 0000000..50d1d54 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/ConfigFile.java @@ -0,0 +1,19 @@ +package com.github.imdmk.playtime.injector.annotations; + +import com.github.imdmk.playtime.injector.ComponentPriority; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface ConfigFile { + + ComponentPriority priority() default ComponentPriority.LOWEST; + + int order() default 0; + +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Controller.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Controller.java new file mode 100644 index 0000000..20d33a0 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Controller.java @@ -0,0 +1,17 @@ +package com.github.imdmk.playtime.injector.annotations; + +import com.github.imdmk.playtime.injector.ComponentPriority; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Controller { + + ComponentPriority priority() default ComponentPriority.HIGHEST; + +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Database.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Database.java new file mode 100644 index 0000000..9625f11 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Database.java @@ -0,0 +1,18 @@ +package com.github.imdmk.playtime.injector.annotations; + +import com.github.imdmk.playtime.injector.ComponentPriority; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Database { + + ComponentPriority priority() default ComponentPriority.LOW; + + int order() default 0; + +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Gui.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Gui.java new file mode 100644 index 0000000..1daae9a --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Gui.java @@ -0,0 +1,16 @@ +package com.github.imdmk.playtime.injector.annotations; + +import com.github.imdmk.playtime.injector.ComponentPriority; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Gui { + + ComponentPriority priority() default ComponentPriority.HIGHEST; + +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Repository.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Repository.java new file mode 100644 index 0000000..924a48b --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Repository.java @@ -0,0 +1,18 @@ +package com.github.imdmk.playtime.injector.annotations; + +import com.github.imdmk.playtime.injector.ComponentPriority; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Repository { + + ComponentPriority priority() default ComponentPriority.LOW; + + int order() default 1; + +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Service.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Service.java new file mode 100644 index 0000000..dd0decb --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/Service.java @@ -0,0 +1,19 @@ +package com.github.imdmk.playtime.injector.annotations; + +import com.github.imdmk.playtime.injector.ComponentPriority; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Service { + + ComponentPriority priority() default ComponentPriority.NORMAL; + + int order() default 1; + +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/lite/LiteArgument.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/lite/LiteArgument.java new file mode 100644 index 0000000..bd5ce2f --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/lite/LiteArgument.java @@ -0,0 +1,19 @@ +package com.github.imdmk.playtime.injector.annotations.lite; + +import com.github.imdmk.playtime.injector.ComponentPriority; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface LiteArgument { + + Class type(); + + String key() default ""; + + ComponentPriority priority() default ComponentPriority.HIGHEST; +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/lite/LiteCommand.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/lite/LiteCommand.java new file mode 100644 index 0000000..9b46b17 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/lite/LiteCommand.java @@ -0,0 +1,16 @@ +package com.github.imdmk.playtime.injector.annotations.lite; + +import com.github.imdmk.playtime.injector.ComponentPriority; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface LiteCommand { + + ComponentPriority priority() default ComponentPriority.HIGHEST; +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/lite/LiteHandler.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/lite/LiteHandler.java new file mode 100644 index 0000000..b996322 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/lite/LiteHandler.java @@ -0,0 +1,17 @@ +package com.github.imdmk.playtime.injector.annotations.lite; + +import com.github.imdmk.playtime.injector.ComponentPriority; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface LiteHandler { + + Class value(); + + ComponentPriority priority() default ComponentPriority.HIGHEST; +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/placeholderapi/Placeholder.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/placeholderapi/Placeholder.java new file mode 100644 index 0000000..9e52410 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/annotations/placeholderapi/Placeholder.java @@ -0,0 +1,16 @@ +package com.github.imdmk.playtime.injector.annotations.placeholderapi; + +import com.github.imdmk.playtime.injector.ComponentPriority; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Placeholder { + + ComponentPriority priority() default ComponentPriority.HIGHEST; + +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentPostProcessor.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentPostProcessor.java new file mode 100644 index 0000000..ef00986 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentPostProcessor.java @@ -0,0 +1,12 @@ +package com.github.imdmk.playtime.injector.processor; + +import org.jetbrains.annotations.NotNull; + +@FunctionalInterface +public interface ComponentPostProcessor { + + void postProcess(@NotNull Object instance, @NotNull ComponentProcessorContext context); + +} + + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessor.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessor.java new file mode 100644 index 0000000..b4640c4 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessor.java @@ -0,0 +1,18 @@ +package com.github.imdmk.playtime.injector.processor; + +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.Annotation; + +public interface ComponentProcessor { + + @NotNull Class annotation(); + + void process( + @NotNull Object instance, + @NotNull A annotation, + @NotNull ComponentProcessorContext context + ); +} + + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessorContext.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessorContext.java new file mode 100644 index 0000000..e29ee84 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessorContext.java @@ -0,0 +1,7 @@ +package com.github.imdmk.playtime.injector.processor; + +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.Injector; + +public record ComponentProcessorContext(@NotNull Injector injector) { +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessorFunctional.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessorFunctional.java new file mode 100644 index 0000000..2e3f289 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessorFunctional.java @@ -0,0 +1,35 @@ +package com.github.imdmk.playtime.injector.processor; + +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.Annotation; + +public final class ComponentProcessorFunctional + implements ComponentProcessor { + + private final Class annotation; + private final ProcessorHandler handler; + + public ComponentProcessorFunctional( + @NotNull Class annotation, + @NotNull ProcessorHandler handler + ) { + this.annotation = annotation; + this.handler = handler; + } + + @NotNull + @Override + public Class annotation() { + return annotation; + } + + @Override + public void process( + @NotNull Object instance, + @NotNull A annotation, + @NotNull ComponentProcessorContext context + ) { + handler.handle(instance, annotation, context); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessors.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessors.java new file mode 100644 index 0000000..14c9b97 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ComponentProcessors.java @@ -0,0 +1,287 @@ +package com.github.imdmk.playtime.injector.processor; + +import com.github.imdmk.playtime.config.ConfigSection; +import com.github.imdmk.playtime.config.ConfigService; +import com.github.imdmk.playtime.database.DatabaseBootstrap; +import com.github.imdmk.playtime.database.repository.ormlite.OrmLiteRepository; +import com.github.imdmk.playtime.injector.annotations.ConfigFile; +import com.github.imdmk.playtime.injector.annotations.Controller; +import com.github.imdmk.playtime.injector.annotations.Database; +import com.github.imdmk.playtime.injector.annotations.Gui; +import com.github.imdmk.playtime.injector.annotations.Repository; +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.injector.annotations.lite.LiteArgument; +import com.github.imdmk.playtime.injector.annotations.lite.LiteCommand; +import com.github.imdmk.playtime.injector.annotations.lite.LiteHandler; +import com.github.imdmk.playtime.injector.annotations.placeholderapi.Placeholder; +import com.github.imdmk.playtime.platform.gui.GuiRegistry; +import com.github.imdmk.playtime.platform.gui.IdentifiableGui; +import com.github.imdmk.playtime.platform.litecommands.LiteCommandsConfigurer; +import com.github.imdmk.playtime.platform.placeholder.PlaceholderService; +import com.github.imdmk.playtime.platform.placeholder.PluginPlaceholder; +import dev.rollczi.litecommands.LiteCommandsBuilder; +import dev.rollczi.litecommands.argument.ArgumentKey; +import dev.rollczi.litecommands.argument.resolver.ArgumentResolver; +import dev.rollczi.litecommands.handler.result.ResultHandler; +import dev.rollczi.litecommands.invalidusage.InvalidUsageHandler; +import dev.rollczi.litecommands.permission.MissingPermissionsHandler; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.Resources; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.lang.reflect.Field; +import java.util.List; + +public final class ComponentProcessors { + + private ComponentProcessors() { + throw new UnsupportedOperationException("This is utility class and cannot be instantiated."); + } + + public static List> defaults(@NotNull Plugin plugin) { + return List.of( + ProcessorBuilder.forAnnotation(Service.class) + .handle((instance, annotation, ctx) -> { + final Resources resources = ctx.injector().getResources(); + + // bind + resources.on(instance.getClass()) + .assignInstance(instance); + + // bind interfaces + for (final Class interfaces : instance.getClass().getInterfaces()) { + resources.on(interfaces).assignInstance(instance); + } + }) + .build(), + + ProcessorBuilder.forAnnotation(ConfigFile.class) + .handle((instance, annotation, ctx) -> ctx.injector().newInstance(ConfigFileProcessor.class).process(instance, annotation, ctx)) + .build(), + + ProcessorBuilder.forAnnotation(Database.class) + .handle((instance, annotation, ctx) -> { + final DatabaseBootstrap databaseBootstrap = requireInstance(instance, DatabaseBootstrap.class, Database.class); + + databaseBootstrap.start(); + ctx.injector().getResources() + .on(DatabaseBootstrap.class) + .assignInstance(databaseBootstrap); + }) + .build(), + + ProcessorBuilder.forAnnotation(Repository.class) + .handle((instance, annotation, ctx) -> { + final OrmLiteRepository repository = requireInstance(instance, OrmLiteRepository.class, OrmLiteRepository.class); + + repository.start(); + ctx.injector().getResources() + .on(repository.getClass()) + .assignInstance(repository); + }) + .build(), + + ProcessorBuilder.forAnnotation(Controller.class) + .handle((instance, annotation, ctx) -> { + final Listener listener = requireInstance(instance, Listener.class, Controller.class); + plugin.getServer() + .getPluginManager() + .registerEvents(listener, plugin); + }) + .build(), + + ProcessorBuilder.forAnnotation(Gui.class) + .handle((instance, annotation, ctx) -> ctx.injector().newInstance(GuiProcessor.class).process(instance, annotation, ctx)) + .build(), + + ProcessorBuilder.forAnnotation(Placeholder.class) + .handle((instance, annotation, ctx) -> ctx.injector().newInstance(PlaceholderProcessor.class).process(instance, annotation, ctx)) + .build(), + + ProcessorBuilder.forAnnotation(LiteCommand.class) + .handle((instance, annotation, ctx) -> ctx.injector().newInstance(LiteCommandProcessor.class).process(instance, annotation, ctx)) + .build(), + + ProcessorBuilder.forAnnotation(LiteHandler.class) + .handle((instance, annotation, ctx) -> ctx.injector().newInstance(LiteHandlerProcessor.class).process(instance, annotation, ctx)) + .build(), + + ProcessorBuilder.forAnnotation(LiteArgument.class) + .handle((instance, annotation, ctx) -> ctx.injector().newInstance(LiteArgumentProcessor.class).process(instance, annotation, ctx)) + .build() + + ); + } + + static T requireInstance( + Object instance, + Class expectedType, + Class annotation + ) { + if (!expectedType.isInstance(instance)) { + throw new IllegalStateException( + "@" + annotation.getSimpleName() + + " can only be used on " + + expectedType.getSimpleName() + + ": " + instance.getClass().getName() + ); + } + + return expectedType.cast(instance); + } + + @Inject + private record GuiProcessor(@NotNull GuiRegistry guiRegistry) + implements ComponentProcessor { + + @Override + public void process(@NotNull Object instance, @NotNull Gui annotation, @NotNull ComponentProcessorContext context) { + final IdentifiableGui identifiableGui = requireInstance(instance, IdentifiableGui.class, Gui.class); + guiRegistry.register(identifiableGui); + } + + @NotNull + @Override + public Class annotation() { + return Gui.class; + } + } + + @Inject + private record ConfigFileProcessor(@NotNull ConfigService configService) + implements ComponentProcessor { + + @Override + public void process( + @NotNull Object instance, + @NotNull ConfigFile annotation, + @NotNull ComponentProcessorContext context + ) { + final Resources resources = context.injector().getResources(); + final ConfigSection config = requireInstance(instance, ConfigSection.class, ConfigFile.class); + + configService.create(config.getClass()); + resources.on(instance.getClass()) + .assignInstance(instance); + + for (final Field field : config.getClass().getFields()) { + try { + final Object value = field.get(config); + if (value != null) { + resources.on(field.getType()) + .assignInstance(value); + } + } + catch (IllegalAccessException ignored) {} + } + } + + @Override + @NotNull + public Class annotation() { + return ConfigFile.class; + } + } + + @Inject + private record PlaceholderProcessor(@NotNull PlaceholderService placeholderService) + implements ComponentProcessor { + + @Override + public void process(@NotNull Object instance, @NotNull Placeholder annotation, @NotNull ComponentProcessorContext context) { + final PluginPlaceholder pluginPlaceholder = requireInstance(instance, PluginPlaceholder.class, Placeholder.class); + placeholderService.register(pluginPlaceholder); + } + + @NotNull + @Override + public Class annotation() { + return Placeholder.class; + } + } + + @Inject + private record LiteHandlerProcessor(@NotNull LiteCommandsConfigurer liteCommandsConfigurer) + implements ComponentProcessor { + + @Override + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void process( + @NotNull Object instance, + @NotNull LiteHandler annotation, + @NotNull ComponentProcessorContext context + ) { + + final LiteCommandsBuilder builder = liteCommandsConfigurer.builder(); + final ResultHandler resultHandler = requireInstance(instance, ResultHandler.class, LiteHandler.class); + + if (resultHandler instanceof InvalidUsageHandler invalidUsageHandler) { + builder.invalidUsage(invalidUsageHandler); + return; + } + + if (resultHandler instanceof MissingPermissionsHandler missingPermissionsHandler) { + builder.missingPermission(missingPermissionsHandler); + return; + } + + builder.result(annotation.value(), resultHandler); + } + + @NotNull + @Override + public Class annotation() { + return LiteHandler.class; + } + } + + @Inject + private record LiteCommandProcessor(@NotNull LiteCommandsConfigurer liteCommandsConfigurer) + implements ComponentProcessor { + + @Override + public void process( + @NotNull Object instance, + @NotNull LiteCommand annotation, + @NotNull ComponentProcessorContext context + ) { + liteCommandsConfigurer.builder().commands(instance); + } + + @NotNull + @Override + public Class annotation() { + return LiteCommand.class; + } + } + + @Inject + private record LiteArgumentProcessor(@NotNull LiteCommandsConfigurer liteCommandsConfigurer) + implements ComponentProcessor { + + @Override + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void process( + @NotNull Object instance, + @NotNull LiteArgument annotation, + @NotNull ComponentProcessorContext context + ) { + final ArgumentResolver argumentResolver = requireInstance(instance, ArgumentResolver.class, LiteArgument.class); + + final LiteCommandsBuilder builder = liteCommandsConfigurer.builder(); + final Class argumentClass = annotation.type(); + final String argumentKey = annotation.key(); + + builder.argument(argumentClass, ArgumentKey.of(argumentKey), argumentResolver); + } + + @NotNull + @Override + public Class annotation() { + return LiteArgument.class; + } + } + +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ProcessorBuilder.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ProcessorBuilder.java new file mode 100644 index 0000000..71fa564 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ProcessorBuilder.java @@ -0,0 +1,40 @@ +package com.github.imdmk.playtime.injector.processor; + +import org.jetbrains.annotations.CheckReturnValue; +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.Annotation; + +public final class ProcessorBuilder { + + private final Class annotation; + + private ProcessorHandler handler; + + private ProcessorBuilder(@NotNull Class annotation) { + this.annotation = annotation; + } + + public static ProcessorBuilder forAnnotation(@NotNull Class annotation) { + return new ProcessorBuilder<>(annotation); + } + + @CheckReturnValue + public ProcessorBuilder handle(@NotNull ProcessorHandler handler) { + this.handler = handler; + return this; + } + + public ProcessorContainer build() { + if (handler == null) { + throw new IllegalStateException( + "Processor for @" + annotation.getSimpleName() + " has no handler defined" + ); + } + + return new ProcessorContainer<>( + annotation, + new ComponentProcessorFunctional<>(annotation, handler) + ); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ProcessorContainer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ProcessorContainer.java new file mode 100644 index 0000000..778f843 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ProcessorContainer.java @@ -0,0 +1,10 @@ +package com.github.imdmk.playtime.injector.processor; + +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.Annotation; + +public record ProcessorContainer( + @NotNull Class annotationType, + @NotNull ComponentProcessor processor +) {} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ProcessorHandler.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ProcessorHandler.java new file mode 100644 index 0000000..fcc27f7 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/processor/ProcessorHandler.java @@ -0,0 +1,13 @@ +package com.github.imdmk.playtime.injector.processor; + +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.Annotation; + +@FunctionalInterface +public interface ProcessorHandler { + + void handle(@NotNull Object instance, @NotNull A annotation, @NotNull ComponentProcessorContext context); + +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/LocalPublisher.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/LocalPublisher.java new file mode 100644 index 0000000..51b54e7 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/LocalPublisher.java @@ -0,0 +1,58 @@ +package com.github.imdmk.playtime.injector.subscriber; + +import com.github.imdmk.playtime.injector.subscriber.event.SubscribeEvent; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.Injector; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class LocalPublisher implements Publisher { + + private final Injector injector; + + private final Map, List> subscribers = new HashMap<>(); + + public LocalPublisher(@NotNull Injector injector) { + this.injector = injector; + } + + @Override + public void subscribe(@NotNull Object instance) { + for (final Method method : instance.getClass().getDeclaredMethods()) { + final Subscribe subscribe = method.getAnnotation(Subscribe.class); + if (subscribe == null) { + continue; + } + + final Class eventType = subscribe.event(); + method.setAccessible(true); + + subscribers + .computeIfAbsent(eventType, k -> new ArrayList<>()) + .add(new SubscriberMethod(instance, method)); + } + } + + @Override + public E publish(@NotNull E event) { + final List list = subscribers.get(event.getClass()); + if (list == null) { + return event; + } + + for (final SubscriberMethod subscriber : list) { + final Object instance = subscriber.instance(); + final Method method = subscriber.method(); + injector.invokeMethod(method, instance, event); + } + + return event; + } + + private record SubscriberMethod(@NotNull Object instance, @NotNull Method method) { } +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/Publisher.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/Publisher.java new file mode 100644 index 0000000..c89eefc --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/Publisher.java @@ -0,0 +1,12 @@ +package com.github.imdmk.playtime.injector.subscriber; + +import com.github.imdmk.playtime.injector.subscriber.event.SubscribeEvent; +import org.jetbrains.annotations.NotNull; + +public interface Publisher { + + void subscribe(@NotNull Object subscriber); + + E publish(@NotNull E event); + +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/Subscribe.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/Subscribe.java new file mode 100644 index 0000000..ac76356 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/Subscribe.java @@ -0,0 +1,17 @@ +package com.github.imdmk.playtime.injector.subscriber; + +import com.github.imdmk.playtime.injector.subscriber.event.SubscribeEvent; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Subscribe { + + Class event(); + +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/event/PlayTimeInitializeEvent.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/event/PlayTimeInitializeEvent.java new file mode 100644 index 0000000..22389b9 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/event/PlayTimeInitializeEvent.java @@ -0,0 +1,4 @@ +package com.github.imdmk.playtime.injector.subscriber.event; + +public final class PlayTimeInitializeEvent extends SubscribeEvent { +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/event/PlayTimeShutdownEvent.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/event/PlayTimeShutdownEvent.java new file mode 100644 index 0000000..f05458c --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/event/PlayTimeShutdownEvent.java @@ -0,0 +1,4 @@ +package com.github.imdmk.playtime.injector.subscriber.event; + +public final class PlayTimeShutdownEvent extends SubscribeEvent { +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/event/SubscribeEvent.java b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/event/SubscribeEvent.java new file mode 100644 index 0000000..353ea75 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/injector/subscriber/event/SubscribeEvent.java @@ -0,0 +1,4 @@ +package com.github.imdmk.playtime.injector.subscriber.event; + +public abstract class SubscribeEvent { +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/message/MessageService.java b/playtime-core/src/main/java/com/github/imdmk/playtime/message/MessageService.java deleted file mode 100644 index 6cf5568..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/message/MessageService.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.github.imdmk.playtime.message; - -import com.eternalcode.multification.adventure.AudienceConverter; -import com.eternalcode.multification.bukkit.BukkitMultification; -import com.eternalcode.multification.notice.provider.NoticeProvider; -import com.eternalcode.multification.translation.TranslationProvider; -import com.github.imdmk.playtime.shared.validate.Validator; -import net.kyori.adventure.platform.AudienceProvider; -import net.kyori.adventure.platform.bukkit.BukkitAudiences; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.serializer.ComponentSerializer; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.NotNull; - -/** - * Central message service for the PlayTime plugin, bridging plugin messages with - * the Adventure and MiniMessage APIs through {@link BukkitMultification}. - * - *

This implementation provides a high-level abstraction for sending messages, - * notices, and components to Bukkit {@link CommandSender}s, automatically converting - * them into Adventure audiences via {@link AudienceProvider}.

- * - *

Features:

- *
    - *
  • Uses {@link MessageConfig} as the single translation source (locale-agnostic).
  • - *
  • Serializes and deserializes Adventure {@link Component}s using {@link MiniMessage}.
  • - *
  • Converts Bukkit {@link CommandSender}s into Adventure audiences automatically.
  • - *
  • Supports both {@link Player} and console senders transparently.
  • - *
- * - *

Thread-safety: Message sending is thread-safe and may be performed - * off the main thread. Underlying Adventure components are immutable and safe for reuse.

- * - * @see MessageConfig - * @see BukkitMultification - * @see MiniMessage - * @see AudienceProvider - * @see NoticeProvider - */ -public final class MessageService extends BukkitMultification { - - private static final MiniMessage DEFAULT_MINI_MESSAGE = MiniMessage.miniMessage(); - - private final MessageConfig messageConfig; - private final AudienceProvider audienceProvider; - private final MiniMessage miniMessage; - - public MessageService( - @NotNull MessageConfig messageConfig, - @NotNull AudienceProvider audienceProvider, - @NotNull MiniMessage miniMessage - ) { - this.messageConfig = Validator.notNull(messageConfig, "messageConfig"); - this.audienceProvider = Validator.notNull(audienceProvider, "audienceProvider"); - this.miniMessage = Validator.notNull(miniMessage, "miniMessage"); - } - - public MessageService(@NotNull MessageConfig messageConfig, @NotNull BukkitAudiences bukkitAudiences) { - this(messageConfig, bukkitAudiences, DEFAULT_MINI_MESSAGE); - } - - public MessageService(@NotNull MessageConfig messageConfig, @NotNull Plugin plugin) { - this(messageConfig, BukkitAudiences.create(plugin), DEFAULT_MINI_MESSAGE); - } - - /** - * Returns a translation provider that always returns the same {@link MessageConfig} instance, - * ignoring locale differences. - * - * @return locale-agnostic translation provider - */ - @Override - protected @NotNull TranslationProvider translationProvider() { - return provider -> messageConfig; - } - - /** - * Returns the {@link MiniMessage}-based component serializer. - * - * @return component serializer for text serialization/deserialization - */ - @Override - protected @NotNull ComponentSerializer serializer() { - return miniMessage; - } - - /** - * Converts Bukkit {@link CommandSender}s into Adventure audiences - * using the configured {@link AudienceProvider}. - * - *

Players are mapped to player audiences, while other senders - * (e.g., console or command blocks) are mapped to {@link AudienceProvider#console()}.

- * - * @return non-null audience converter - */ - @Override - protected @NotNull AudienceConverter audienceConverter() { - return sender -> { - if (sender instanceof Player player) { - return audienceProvider.player(player.getUniqueId()); - } - return audienceProvider.console(); - }; - } - - /** - * Sends a localized or static notice message to the specified Bukkit {@link CommandSender}. - * - *

The notice is resolved through the active {@link MessageConfig} - * and rendered using {@link MiniMessage} formatting.

- * - * @param sender non-null Bukkit command sender (player, console, etc.) - * @param notice non-null notice provider bound to {@link MessageConfig} - * @throws NullPointerException if {@code sender} or {@code notice} is null - */ - public void send(@NotNull CommandSender sender, @NotNull NoticeProvider notice) { - Validator.notNull(sender, "sender"); - Validator.notNull(notice, "notice"); - create().viewer(sender).notice(notice).send(); - } - - /** - * Shuts down the underlying {@link AudienceProvider} to release Adventure resources. - * - *

This should be called during plugin disable to avoid memory leaks or - * lingering references to the plugin classloader.

- */ - public void shutdown() { - Validator.notNull(audienceProvider, "audienceProvider cannot be null"); - audienceProvider.close(); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/ComponentSerializer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventureComponentSerializer.java similarity index 91% rename from playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/ComponentSerializer.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventureComponentSerializer.java index f4ce438..6ecaad9 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/ComponentSerializer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventureComponentSerializer.java @@ -7,7 +7,7 @@ import net.kyori.adventure.text.Component; import org.jetbrains.annotations.NotNull; -public final class ComponentSerializer implements ObjectSerializer { +public final class AdventureComponentSerializer implements ObjectSerializer { @Override public boolean supports(@NotNull Class type) { diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventureComponents.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventureComponents.java index 9de1aea..5639c0a 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventureComponents.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventureComponents.java @@ -1,6 +1,5 @@ package com.github.imdmk.playtime.platform.adventure; -import com.github.imdmk.playtime.shared.validate.Validator; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.ComponentLike; import net.kyori.adventure.text.format.TextDecoration; @@ -11,21 +10,6 @@ import java.util.Collection; import java.util.List; -/** - * Utilities for working with Adventure {@link Component}s via MiniMessage. - * Platform-agnostic (no Bukkit types). Thread-safe and stateless. - * - *

Notes: - *

    - *
  • All returned collections are unmodifiable.
  • - *
  • Accepts {@link CharSequence} for flexibility.
  • - *
- * - *
- *   Component c = AdventureComponents.text("<red>Hello");
- *   Component plain = AdventureComponents.withoutItalics(c);
- * 
- */ public final class AdventureComponents { private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage(); @@ -34,26 +18,11 @@ private AdventureComponents() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - /** - * Deserializes a MiniMessage-formatted text into a {@link Component}. - * - * @param text the MiniMessage-formatted text - * @return the deserialized component - */ - public static @NotNull Component text(@NotNull CharSequence text) { - Validator.notNull(text, "text"); + public static Component text(@NotNull CharSequence text) { return MINI_MESSAGE.deserialize(text.toString()); } - /** - * Deserializes multiple MiniMessage-formatted texts into a list of {@link Component}s. - * - * @param texts array of MiniMessage-formatted texts - * @return an unmodifiable list of deserialized components - */ - public static @NotNull List text(@NotNull CharSequence... texts) { - Validator.notNull(texts, "texts"); - + public static List text(@NotNull CharSequence... texts) { final List out = new ArrayList<>(texts.length); for (CharSequence text : texts) { out.add(MINI_MESSAGE.deserialize(text.toString())); @@ -62,121 +31,59 @@ private AdventureComponents() { return List.copyOf(out); } - /** - * Deserializes a collection of MiniMessage-formatted texts into {@link Component}s. - * - * @param texts iterable of MiniMessage-formatted texts - * @return an unmodifiable list of deserialized components - */ - public static @NotNull List text(@NotNull Iterable texts) { - Validator.notNull(texts, "texts"); - + public static List text(@NotNull Iterable texts) { final List out = new ArrayList<>(); for (CharSequence text : texts) { - Validator.notNull(text, "texts contains null element"); out.add(MINI_MESSAGE.deserialize(text.toString())); } return List.copyOf(out); } - /** - * Returns a copy of the given component with italics disabled. - * - * @param component the source component - * @return a new component without italics - */ - public static @NotNull Component withoutItalics(@NotNull Component component) { - Validator.notNull(component, "component"); + public static Component withoutItalics(@NotNull Component component) { return component.decoration(TextDecoration.ITALIC, false); } - /** - * Deserializes a MiniMessage-formatted text and removes italics. - * - * @param text the MiniMessage-formatted text - * @return a deserialized component without italics - */ - public static @NotNull Component withoutItalics(@NotNull CharSequence text) { + public static Component withoutItalics(@NotNull CharSequence text) { return withoutItalics(text(text)); } - /** - * Converts a {@link ComponentLike} into a {@link Component} and removes italics. - * - * @param like the source component-like object - * @return a new component without italics - */ - public static @NotNull Component withoutItalics(@NotNull ComponentLike like) { - Validator.notNull(like, "component"); + public static Component withoutItalics(@NotNull ComponentLike like) { return like.asComponent().decoration(TextDecoration.ITALIC, false); } - /** - * Disables italics for all given components. - * - * @param strings iterable of strings objects - * @return an unmodifiable list of components with italics disabled - */ - public static @NotNull List withoutItalics(@NotNull String... strings) { - Validator.notNull(strings, "components cannot be null"); - + public static List withoutItalics(@NotNull String... strings) { final List out = new ArrayList<>(); for (final String string : strings) { - Validator.notNull(string, "components contains null element"); out.add(withoutItalics(string)); } return List.copyOf(out); } - /** - * Serializes a {@link Component} into a MiniMessage-formatted string. - * - * @param component the component to serialize - * @return the serialized MiniMessage string - */ - public static @NotNull String serialize(@NotNull Component component) { - Validator.notNull(component, "component cannot be null"); + public static String serialize(@NotNull Component component) { return MINI_MESSAGE.serialize(component); } - /** - * Serializes multiple components into MiniMessage-formatted strings. - * - * @param components collection of component-like objects - * @return an unmodifiable list of serialized strings - */ - public static @NotNull List serialize(@NotNull Collection components) { - Validator.notNull(components, "components cannot be null"); - + public static List serialize(@NotNull Collection components) { final List out = new ArrayList<>(components.size()); for (final ComponentLike component : components) { - Validator.notNull(component, "components contains null element"); out.add(MINI_MESSAGE.serialize(component.asComponent())); } return List.copyOf(out); } - /** - * Serializes multiple components and joins them with the given delimiter. - * - * @param components collection of component-like objects - * @param delimiter string separator between serialized components - * @return a single joined MiniMessage string - */ - public static @NotNull String serializeJoined(@NotNull Collection components, - @NotNull CharSequence delimiter) { - Validator.notNull(components, "components cannot be null"); - Validator.notNull(delimiter, "delimiter cannot be null"); - + public static String serializeJoined(@NotNull Collection components, @NotNull CharSequence delimiter) { final List serialized = new ArrayList<>(components.size()); for (final ComponentLike component : components) { - Validator.notNull(component, "components contains null element"); serialized.add(MINI_MESSAGE.serialize(component.asComponent())); } return String.join(delimiter, serialized); } + + public static MiniMessage miniMessage() { + return MINI_MESSAGE; + } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventureFormatter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventureFormatter.java index 744887d..e62ac1c 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventureFormatter.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventureFormatter.java @@ -1,8 +1,8 @@ package com.github.imdmk.playtime.platform.adventure; -import com.github.imdmk.playtime.shared.validate.Validator; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextReplacementConfig; +import net.kyori.adventure.text.minimessage.MiniMessage; import org.jetbrains.annotations.NotNull; import java.util.Comparator; @@ -10,68 +10,38 @@ import java.util.Map; import java.util.stream.Collectors; -/** - * Utility for applying {@link AdventurePlaceholders} to {@link Component} trees or plain strings. - *

Stateless and thread-safe.

- */ public final class AdventureFormatter { private AdventureFormatter() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } - /** - * Applies placeholders to a plain string and returns a formatted {@link Component}. - * - * @param input plain text input - * @param placeholders placeholders to apply - * @return formatted component - */ - public static @NotNull Component format(@NotNull String input, @NotNull AdventurePlaceholders placeholders) { - Validator.notNull(input, "input"); + public static Component format(@NotNull String input, @NotNull AdventurePlaceholders placeholders) { return format(AdventureComponents.text(input), placeholders); } - /** - * Applies placeholders to each {@link Component} in a list. - * - * @param components list of components - * @param placeholders placeholders to apply - * @return formatted components - */ - public static @NotNull List format(@NotNull List components, @NotNull AdventurePlaceholders placeholders) { - Validator.notNull(components, "components"); + public static List format(@NotNull List components, @NotNull AdventurePlaceholders placeholders) { return components.stream() .map(component -> format(component, placeholders)) .collect(Collectors.toList()); } - /** - * Applies placeholders to a single {@link Component}. - * - * @param input component to format - * @param placeholders placeholders to apply - * @return formatted component - */ - public static @NotNull Component format(@NotNull Component input, @NotNull AdventurePlaceholders placeholders) { - Validator.notNull(input, "input"); - Validator.notNull(placeholders, "placeholders"); - + public static Component format(@NotNull Component input, @NotNull AdventurePlaceholders placeholders) { if (placeholders.isEmpty()) { return input; } // Sort keys by descending length to avoid substring overlap - List> ordered = placeholders.asMap().entrySet().stream() + final var ordered = placeholders.asMap().entrySet().stream() .sorted(Comparator.>comparingInt(e -> e.getKey().length()).reversed()) - .collect(Collectors.toList()); + .toList(); Component out = input; - for (final Map.Entry e : ordered) { - var key = e.getKey(); - var replacement = e.getValue(); + for (final var entry : ordered) { + final String key = entry.getKey(); + final Component replacement = entry.getValue(); - var config = TextReplacementConfig.builder() + final TextReplacementConfig config = TextReplacementConfig.builder() .matchLiteral(key) .replacement(replacement) .build(); @@ -81,4 +51,5 @@ private AdventureFormatter() { return out; } + } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventurePlaceholders.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventurePlaceholders.java index 5a7926c..7481fec 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventurePlaceholders.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/adventure/AdventurePlaceholders.java @@ -1,6 +1,5 @@ package com.github.imdmk.playtime.platform.adventure; -import com.github.imdmk.playtime.shared.validate.Validator; import net.kyori.adventure.text.Component; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; @@ -10,13 +9,6 @@ import java.util.LinkedHashMap; import java.util.Map; -/** - * Immutable container mapping literal placeholder keys to Adventure {@link Component} values. - *

- * Instances are created via the {@link Builder}. Once built, the mapping is read-only. - *

- * Thread-safety: Fully immutable and safe for concurrent use. - */ public final class AdventurePlaceholders { private static final AdventurePlaceholders EMPTY = new AdventurePlaceholders(Map.of()); @@ -24,130 +16,59 @@ public final class AdventurePlaceholders { private final Map map; private AdventurePlaceholders(@NotNull Map map) { - Validator.notNull(map, "map"); this.map = Collections.unmodifiableMap(map); } - /** - * Returns an unmodifiable view of all placeholder mappings. - * - * @return unmodifiable placeholder map - */ @Unmodifiable - @NotNull public Map asMap() { return map; } - /** - * Returns the number of registered placeholders. - * - * @return placeholder count - */ public int size() { return map.size(); } - /** - * Checks if the placeholder map is empty. - * - * @return {@code true} if no placeholders are defined - */ public boolean isEmpty() { return map.isEmpty(); } - /** - * Returns a shared immutable empty instance. - * - * @return empty placeholder container - */ - public static @NotNull AdventurePlaceholders empty() { + public static AdventurePlaceholders empty() { return EMPTY; } - /** - * Creates a new builder for {@link AdventurePlaceholders}. - * - * @return new builder instance - */ - public static @NotNull Builder builder() { + public static Builder builder() { return new Builder(); } - /** - * Fluent builder for {@link AdventurePlaceholders}. - */ public static final class Builder { private final Map entries = new LinkedHashMap<>(); - /** - * Adds a literal → component mapping. - * - * @param key literal placeholder key - * @param value replacement component - * @return this builder for chaining - */ @Contract("_,_ -> this") - public @NotNull Builder with(@NotNull String key, @NotNull Component value) { - Validator.notNull(key, "key"); - Validator.notNull(value, "value"); + public Builder with(@NotNull String key, @NotNull Component value) { this.entries.put(key, value); return this; } - /** - * Adds a literal → plain text mapping (converted to {@link Component#text(String)}). - * - * @param key literal placeholder key - * @param value replacement text - * @return this builder for chaining - */ @Contract("_,_ -> this") - public @NotNull Builder with(@NotNull String key, @NotNull String value) { - Validator.notNull(key, "key cannot be null"); - Validator.notNull(value, "value cannot be null"); + public Builder with(@NotNull String key, @NotNull String value) { this.entries.put(key, Component.text(value)); return this; } - /** - * Adds all entries from another {@link AdventurePlaceholders}. - * - * @param other another placeholder container - * @return this builder for chaining - */ @Contract("_ -> this") - public @NotNull Builder with(@NotNull AdventurePlaceholders other) { - Validator.notNull(other, "other cannot be null"); + public Builder with(@NotNull AdventurePlaceholders other) { this.entries.putAll(other.asMap()); return this; } - /** - * Adds a placeholder using any object value. - * The value is converted to plain text via {@link String#valueOf(Object)}. - * - * @param key placeholder key - * @param value object to convert and insert - * @return this builder for chaining - * @throws NullPointerException if key or value is null - */ @Contract("_,_ -> this") - public @NotNull Builder with(@NotNull String key, @NotNull Object value) { - Validator.notNull(key, "key cannot be null"); - Validator.notNull(value, "value cannot be null"); + public Builder with(@NotNull String key, @NotNull Object value) { this.entries.put(key, Component.text(String.valueOf(value))); return this; } - /** - * Builds an immutable {@link AdventurePlaceholders} instance. - * - * @return immutable placeholder container - */ - public @NotNull AdventurePlaceholders build() { + public AdventurePlaceholders build() { if (this.entries.isEmpty()) { return EMPTY; } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/event/BukkitEventCaller.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/event/BukkitEventCaller.java new file mode 100644 index 0000000..87ca029 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/event/BukkitEventCaller.java @@ -0,0 +1,34 @@ +package com.github.imdmk.playtime.platform.event; + +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; +import org.bukkit.Server; +import org.bukkit.event.Event; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +@Service(priority = ComponentPriority.LOW) +final class BukkitEventCaller implements EventCaller { + + private final Server server; + private final TaskScheduler scheduler; + + @Inject + BukkitEventCaller(@NotNull Server server, @NotNull TaskScheduler scheduler) { + this.server = server; + this.scheduler = scheduler; + } + + @Override + public E callEvent(@NotNull E event) { + if (event.isAsynchronous() || server.isPrimaryThread()) { + server.getPluginManager().callEvent(event); + return event; + } + + scheduler.runSync(() -> server.getPluginManager().callEvent(event)); + return event; + } + +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/event/EventCaller.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/event/EventCaller.java new file mode 100644 index 0000000..4de83c9 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/event/EventCaller.java @@ -0,0 +1,10 @@ +package com.github.imdmk.playtime.platform.event; + +import org.bukkit.event.Event; +import org.jetbrains.annotations.NotNull; + +public interface EventCaller { + + E callEvent(@NotNull E event); + +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/events/BukkitEventCaller.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/events/BukkitEventCaller.java deleted file mode 100644 index 5a7a3a5..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/events/BukkitEventCaller.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.github.imdmk.playtime.platform.events; - -import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.bukkit.Server; -import org.bukkit.event.Event; -import org.jetbrains.annotations.NotNull; - -/** - * Utility wrapper for safely firing Bukkit {@link Event}s. - * Ensures that synchronous events are always fired on the primary server thread. - */ -public final class BukkitEventCaller { - - private final Server server; - private final TaskScheduler scheduler; - - public BukkitEventCaller(@NotNull Server server, @NotNull TaskScheduler scheduler) { - this.server = Validator.notNull(server, "server cannot be null"); - this.scheduler = Validator.notNull(scheduler, "scheduler cannot be null"); - } - - /** - * Calls the specified Bukkit event ensuring correct thread usage: - *

    - *
  • Asynchronous events are fired on the current thread;
  • - *
  • Synchronous events are fired on the primary server thread.
  • - *
- */ - public T callEvent(@NotNull T event) { - Validator.notNull(event, "event cannot be null"); - - if (event.isAsynchronous()) { - server.getPluginManager().callEvent(event); - return event; - } - - if (server.isPrimaryThread()) { - server.getPluginManager().callEvent(event); - } else { - scheduler.runSync(() -> server.getPluginManager().callEvent(event)); - } - - return event; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/events/BukkitListenerRegistrar.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/events/BukkitListenerRegistrar.java deleted file mode 100644 index 10f1f0b..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/events/BukkitListenerRegistrar.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.github.imdmk.playtime.platform.events; - -import com.github.imdmk.playtime.shared.validate.Validator; -import org.bukkit.event.Listener; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.Injector; - -/** - * Utility component responsible for registering Bukkit {@link Listener}s. - *

- * This registrar provides two registration modes: - *

    - *
  • Direct registration of pre-instantiated listener objects.
  • - *
  • Automatic instantiation and field injection through {@link Injector}.
  • - *
- *

- * All listeners are registered using the plugin's {@link org.bukkit.plugin.PluginManager}. - */ -public final class BukkitListenerRegistrar { - - private final Plugin plugin; - - /** - * Creates a new registrar for the given Bukkit plugin. - * - * @param plugin the plugin instance used for listener registration - * @throws NullPointerException if the plugin is null - */ - public BukkitListenerRegistrar(@NotNull Plugin plugin) { - this.plugin = Validator.notNull(plugin, "plugin cannot be null"); - } - - /** - * Registers the provided listener instances with the Bukkit {@link org.bukkit.plugin.PluginManager}. - * - * @param listeners the listener instances to register - * @throws NullPointerException if the listeners array or any listener is null - */ - public void register(@NotNull Listener... listeners) { - Validator.notNull(listeners, "listeners cannot be null"); - for (final Listener listener : listeners) { - plugin.getServer().getPluginManager().registerEvents(listener, plugin); - } - } - - /** - * Creates and registers listeners using the provided {@link Injector}. - *

- * Each listener class is instantiated and its dependencies are injected automatically. - * - * @param injector the dependency injector to use for listener instantiation - * @param listeners the listener classes to create and register - * @throws NullPointerException if the injector, the listener array, or any class is null - */ - @SafeVarargs - public final void register(@NotNull Injector injector, @NotNull Class... listeners) { - Validator.notNull(injector, "injector cannot be null"); - Validator.notNull(listeners, "listeners cannot be null"); - - for (final Class listenerClass : listeners) { - register(injector.newInstance(listenerClass)); - } - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/GuiModule.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/GuiModule.java deleted file mode 100644 index c2ebe85..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/GuiModule.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.imdmk.playtime.platform.gui; - -import com.github.imdmk.playtime.infrastructure.module.Module; -import com.github.imdmk.playtime.platform.gui.render.GuiRenderer; -import com.github.imdmk.playtime.platform.gui.render.TriumphGuiRenderer; -import com.github.imdmk.playtime.platform.gui.view.GuiOpener; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.Injector; -import org.panda_lang.utilities.inject.Resources; - -public final class GuiModule implements Module { - - private GuiOpener guiOpener; - private GuiRenderer guiRenderer; - - @Override - public void bind(@NotNull Resources resources) { - resources.on(GuiOpener.class).assignInstance(() -> this.guiOpener); - resources.on(GuiRenderer.class).assignInstance(() -> this.guiRenderer); - } - - @Override - public void init(@NotNull Injector injector) { - this.guiOpener = injector.newInstance(GuiOpener.class); - this.guiRenderer = injector.newInstance(TriumphGuiRenderer.class); - } - - @Override - public int order() { - return 10; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/GuiRegistry.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/GuiRegistry.java index c996139..1dd0944 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/GuiRegistry.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/GuiRegistry.java @@ -1,35 +1,24 @@ package com.github.imdmk.playtime.platform.gui; -import com.github.imdmk.playtime.shared.validate.Validator; +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.injector.subscriber.Subscribe; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeShutdownEvent; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; import java.util.Locale; import java.util.Map; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -/** - * Thread-safe registry of {@link IdentifiableGui}, keyed by normalized id. - * Invariant: at most one GUI per concrete class (maintained class index). - */ +@Service(priority = ComponentPriority.LOW) public final class GuiRegistry { private final Map byId = new ConcurrentHashMap<>(); private final Map, IdentifiableGui> byClass = new ConcurrentHashMap<>(); - /** - * Registers (or replaces) GUI by its normalized identifier. - * Also updates class index (one instance per class). - * - * @return previously registered GUI under the same id, or {@code null}. - */ - @Nullable - public IdentifiableGui register(@NotNull IdentifiableGui gui) { - Validator.notNull(gui, "gui cannot be null"); - final String id = normalize(Validator.notNull(gui.getId(), "gui identifier cannot be null")); - + public void register(@NotNull IdentifiableGui gui) { + final String id = normalizeId(gui.getId()); final IdentifiableGui previous = byId.put(id, gui); // maintain class index (assume single instance per class) @@ -40,18 +29,10 @@ public IdentifiableGui register(@NotNull IdentifiableGui gui) { if (previous != null && previous.getClass() != type) { byClass.compute(previous.getClass(), (k, current) -> current == previous ? null : current); } - return previous; } - /** - * Registers GUI only if absent under the same id. - * - * @return {@code true} if registered, {@code false} if id existed. - */ public boolean registerIfAbsent(@NotNull IdentifiableGui gui) { - Validator.notNull(gui, "gui cannot be null"); - final String id = normalize(Validator.notNull(gui.getId(), "gui identifier cannot be null")); - + final String id = normalizeId(gui.getId()); final IdentifiableGui existing = byId.putIfAbsent(id, gui); if (existing == null) { // we won the race; update class index @@ -61,12 +42,12 @@ public boolean registerIfAbsent(@NotNull IdentifiableGui gui) { return false; } - /** - * Unregisters GUI by id. Updates class index if pointing to same instance. - */ - @Nullable + public boolean isRegistered(@NotNull String id) { + return byId.containsKey(normalizeId(id)); + } + public IdentifiableGui unregister(@NotNull String id) { - final String key = normalize(Validator.notNull(id, "id cannot be null")); + final String key = normalizeId(id); final IdentifiableGui removed = byId.remove(key); if (removed != null) { byClass.compute(removed.getClass(), (k, current) -> current == removed ? null : current); @@ -74,41 +55,24 @@ public IdentifiableGui unregister(@NotNull String id) { return removed; } - /** - * Case-insensitive lookup by id (whitespace-insensitive). - */ + @Subscribe(event = PlayTimeShutdownEvent.class) + private void shutdown() { + byId.clear(); + byClass.clear(); + } + @Nullable public IdentifiableGui getById(@NotNull String id) { - final String key = normalize(Validator.notNull(id, "id cannot be null")); - return byId.get(key); + return byId.get(normalizeId(id)); } - /** - * O(1) exact type lookup. Assumes at most one instance per class. - */ @Nullable + @SuppressWarnings("unchecked") public T getByClass(@NotNull Class type) { - Validator.notNull(type, "type cannot be null"); - final IdentifiableGui gui = byClass.get(type); - @SuppressWarnings("unchecked") - final T cast = (T) gui; - return cast; - } - - public boolean isRegistered(@NotNull String id) { - final String key = normalize(Validator.notNull(id, "id cannot be null")); - return byId.containsKey(key); - } - - /** Immutable snapshot of normalized ids. */ - @Unmodifiable - public Set ids() { - return Set.copyOf(byId.keySet()); + return (T) byClass.get(type); } - /** Current strategy: trim + lowercased (Locale.ROOT). */ - private static String normalize(@NotNull String id) { - final String trimmed = id.trim(); - return trimmed.toLowerCase(Locale.ROOT); + private static String normalizeId(String id) { + return id.trim().toLowerCase(Locale.ROOT); } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/GuiType.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/GuiType.java index a17c8e5..1dd71ac 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/GuiType.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/GuiType.java @@ -1,33 +1,8 @@ package com.github.imdmk.playtime.platform.gui; -/** - * Defines the supported GUI layout types within the plugin. - * - *

Each type represents a different interaction model for displaying items.

- */ public enum GuiType { - - /** - * A fixed-size GUI without pagination or scrolling. - * Suitable for simple static interfaces. - */ STANDARD, - - /** - * A multipage GUI used for displaying large sets of items. - * Provides navigation between pages. - */ PAGINATED, - - /** - * A GUI that supports vertical scrolling. - * Ideal for lists of items exceeding the visible height. - */ SCROLLING_VERTICAL, - - /** - * A GUI that supports horizontal scrolling. - * Useful for side-by-side item navigation. - */ SCROLLING_HORIZONTAL } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/IdentifiableGui.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/IdentifiableGui.java index f9f0a9d..8e9e94a 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/IdentifiableGui.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/IdentifiableGui.java @@ -1,18 +1,7 @@ package com.github.imdmk.playtime.platform.gui; -import org.jetbrains.annotations.NotNull; -/** - * Represents a GUI component that can be uniquely identified by a string identifier. - *

- * Useful for registering and retrieving GUI instances by their identifier. - */ -@FunctionalInterface public interface IdentifiableGui { - /** - * Returns the unique identifier for this GUI. - * - * @return the non-null unique identifier string - */ - @NotNull String getId(); + String getId(); + } \ No newline at end of file diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/ConfigurableGui.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/ConfigurableGui.java index a15678f..44a0e4e 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/ConfigurableGui.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/ConfigurableGui.java @@ -2,27 +2,13 @@ import com.github.imdmk.playtime.platform.gui.GuiType; import net.kyori.adventure.text.Component; -import org.jetbrains.annotations.NotNull; -/** - * Represents a configurable GUI loaded from configuration. - * Implementations should provide all basic GUI metadata and content definitions. - */ public interface ConfigurableGui { - /** - * @return GUI title as Adventure {@link Component} - */ - @NotNull Component title(); + Component title(); - /** - * @return GUI type (e.g. {@link GuiType#STANDARD}, {@link GuiType#PAGINATED}) - */ - @NotNull GuiType type(); + GuiType type(); - /** - * @return GUI rows - */ int rows(); } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/GuiConfig.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/GuiConfig.java index 4b08026..98a0138 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/GuiConfig.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/GuiConfig.java @@ -1,27 +1,24 @@ package com.github.imdmk.playtime.platform.gui.config; import com.github.imdmk.playtime.config.ConfigSection; -import com.github.imdmk.playtime.feature.playtime.gui.PlayTimeTopGuiConfig; -import com.github.imdmk.playtime.platform.adventure.ComponentSerializer; +import com.github.imdmk.playtime.injector.annotations.ConfigFile; +import com.github.imdmk.playtime.platform.adventure.AdventureComponentSerializer; import com.github.imdmk.playtime.platform.gui.item.ItemGuiSerializer; import com.github.imdmk.playtime.platform.serdes.EnchantmentSerializer; import com.github.imdmk.playtime.platform.serdes.SoundSerializer; import eu.okaeri.configs.annotation.Comment; import eu.okaeri.configs.serdes.OkaeriSerdesPack; -import org.jetbrains.annotations.NotNull; +@ConfigFile public final class GuiConfig extends ConfigSection { - @Comment({"#", "# Playtime top GUI", "#"}) - public PlayTimeTopGuiConfig playtimeTopGui = new PlayTimeTopGuiConfig(); - @Comment({"#", "# Navigation Bar", "#"}) public NavigationBarConfig navigationBar = new NavigationBarConfig(); @Override - public @NotNull OkaeriSerdesPack getSerdesPack() { + public OkaeriSerdesPack serdesPack() { return registry -> { - registry.register(new ComponentSerializer()); + registry.register(new AdventureComponentSerializer()); registry.register(new ItemGuiSerializer()); registry.register(new EnchantmentSerializer()); registry.register(new SoundSerializer()); @@ -29,7 +26,7 @@ public final class GuiConfig extends ConfigSection { } @Override - public @NotNull String getFileName() { - return "guiConfig.yml"; + public String fileName() { + return "gui.yml"; } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/NavigationBarConfig.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/NavigationBarConfig.java index 8e828c1..c3755a5 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/NavigationBarConfig.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/config/NavigationBarConfig.java @@ -6,12 +6,6 @@ import eu.okaeri.configs.annotation.Comment; import org.bukkit.Material; -/** - * Configuration for navigation items used in paginated GUIs. - *

- * Defines visual representation and behavior for navigation controls: - * next, previous, and exit buttons displayed in inventory-based interfaces. - */ public final class NavigationBarConfig extends OkaeriConfig { @Comment({ @@ -100,4 +94,5 @@ public final class NavigationBarConfig extends OkaeriConfig { " " )) .build(); + } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/factory/GuiBuilderFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/factory/GuiBuilderFactory.java index e5cfff8..d901870 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/factory/GuiBuilderFactory.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/factory/GuiBuilderFactory.java @@ -1,39 +1,20 @@ package com.github.imdmk.playtime.platform.gui.factory; import com.github.imdmk.playtime.platform.gui.GuiType; -import com.github.imdmk.playtime.shared.validate.Validator; import dev.triumphteam.gui.builder.gui.BaseGuiBuilder; import dev.triumphteam.gui.components.ScrollType; import dev.triumphteam.gui.guis.Gui; -import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import java.util.function.Consumer; -/** - * Factory for creating TriumphGUI {@link BaseGuiBuilder} instances - * based on a provided {@link GuiType}. - *

- * Supports standard, paginated, and scrolling (vertical/horizontal) GUIs. - */ public final class GuiBuilderFactory { private GuiBuilderFactory() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - /** - * Returns a TriumphGUI builder matching the given {@link GuiType}. - * - * @param type the GUI type - * @param rows the GUI rows - * @return a new {@link BaseGuiBuilder} instance for the given type - * @throws IllegalArgumentException if {@code type} is {@code null} - */ - @Contract(pure = true) - public static @NotNull BaseGuiBuilder forType(@NotNull GuiType type, int rows) { - Validator.notNull(type, "type cannot be null"); - + public static BaseGuiBuilder forType(@NotNull GuiType type, int rows) { return switch (type) { case STANDARD -> Gui.gui().rows(rows); case PAGINATED -> Gui.paginated().rows(rows); @@ -42,20 +23,8 @@ private GuiBuilderFactory() { }; } - /** - * Creates and immediately customizes a TriumphGUI builder. - * - * @param type the GUI type - * @param rows the GUI rows - * @param editConsumer consumer for post-creation customization (e.g., size, disableAllInteractions) - * @return a modified {@link BaseGuiBuilder} instance - * @throws IllegalArgumentException if {@code type} or {@code editConsumer} is {@code null} - */ - public static @NotNull BaseGuiBuilder forType(@NotNull GuiType type, int rows, @NotNull Consumer> editConsumer) { - Validator.notNull(type, "type cannot be null"); - Validator.notNull(editConsumer, "editConsumer cannot be null"); - - BaseGuiBuilder builder = forType(type, rows); + public static BaseGuiBuilder forType(@NotNull GuiType type, int rows, @NotNull Consumer> editConsumer) { + final BaseGuiBuilder builder = forType(type, rows); editConsumer.accept(builder); return builder; } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/factory/GuiFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/factory/GuiFactory.java index cee734d..190904a 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/factory/GuiFactory.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/factory/GuiFactory.java @@ -1,54 +1,25 @@ package com.github.imdmk.playtime.platform.gui.factory; import com.github.imdmk.playtime.platform.gui.config.ConfigurableGui; -import com.github.imdmk.playtime.shared.validate.Validator; import dev.triumphteam.gui.guis.BaseGui; import org.jetbrains.annotations.NotNull; import java.util.function.Consumer; -/** - * Factory for creating {@link BaseGui} instances from {@link ConfigurableGui}. - *

- * Responsibilities: - *

    - *
  • Delegate to {@link GuiBuilderFactory} based on configured type,
  • - *
  • Apply base attributes (e.g. title),
  • - *
  • Optionally allow post-creation customization via a {@link Consumer}.
  • - *
- */ public final class GuiFactory { private GuiFactory() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - /** - * Builds a GUI instance from the provided configuration. - * - * @param config the GUI configuration - * @return a new {@link BaseGui} instance - * @throws IllegalArgumentException if the GUI type is unsupported - */ - public static @NotNull BaseGui build(@NotNull ConfigurableGui config) { - Validator.notNull(config, "config cannot be null"); + public static BaseGui build(@NotNull ConfigurableGui config) { return GuiBuilderFactory.forType(config.type(), config.rows()) .title(config.title()) .create(); } - /** - * Builds and immediately customizes a GUI using the provided consumer. - * - * @param config the GUI configuration - * @param editConsumer consumer to modify the GUI instance before returning - * @return the configured {@link BaseGui} - */ - public static @NotNull BaseGui build(@NotNull ConfigurableGui config, @NotNull Consumer editConsumer) { - Validator.notNull(config, "config cannot be null"); - Validator.notNull(editConsumer, "editConsumer cannot be null"); - - BaseGui gui = build(config); + public static BaseGui build(@NotNull ConfigurableGui config, @NotNull Consumer editConsumer) { + final BaseGui gui = build(config); editConsumer.accept(gui); return gui; } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGui.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGui.java index f9f967b..c0db2da 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGui.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGui.java @@ -1,6 +1,5 @@ package com.github.imdmk.playtime.platform.gui.item; -import com.github.imdmk.playtime.shared.validate.Validator; import net.kyori.adventure.text.Component; import org.bukkit.Material; import org.bukkit.enchantments.Enchantment; @@ -16,12 +15,6 @@ import java.util.List; import java.util.Map; -/** - * Immutable data model representing a GUI item definition. - *

- * Pure data – no logic, no rendering. - * Used to describe items in configuration and GUI assembly layers. - */ public record ItemGui( @NotNull Material material, @NotNull Component name, @@ -34,10 +27,6 @@ public record ItemGui( ) { public ItemGui { - Validator.notNull(material, "material cannot be null"); - Validator.notNull(name, "name cannot be null"); - Validator.notNull(lore, "lore cannot be null"); - lore = List.copyOf(lore); if (enchantments != null) { @@ -67,21 +56,20 @@ public static final class Builder { @CheckReturnValue @Contract(value = "_ -> this", mutates = "this") public Builder material(@NotNull Material material) { - this.material = Validator.notNull(material, "material cannot be null"); + this.material = material; return this; } @CheckReturnValue @Contract(value = "_ -> this", mutates = "this") public Builder name(@NotNull Component name) { - this.name = Validator.notNull(name, "name cannot be null"); + this.name = name; return this; } @CheckReturnValue @Contract(value = "_ -> this", mutates = "this") public Builder lore(@NotNull List lore) { - Validator.notNull(lore, "lore cannot be null"); this.lore = List.copyOf(lore); return this; } @@ -117,9 +105,6 @@ public Builder requiredPermission(@Nullable String permission) { @CheckReturnValue @Contract(value = "_, _ -> this", mutates = "this") public Builder addEnchantment(@NotNull Enchantment enchantment, @NotNull Integer level) { - Validator.notNull(enchantment, "enchantment cannot be null"); - Validator.notNull(level, "level cannot be null"); - Map newEnchantments = new HashMap<>(this.enchantments); newEnchantments.put(enchantment, level); this.enchantments = Map.copyOf(newEnchantments); @@ -129,8 +114,6 @@ public Builder addEnchantment(@NotNull Enchantment enchantment, @NotNull Integer @CheckReturnValue @Contract(value = "_ -> this", mutates = "this") public Builder addFlags(@NotNull ItemFlag... toAdd) { - Validator.notNull(toAdd, "flags cannot be null"); - List newFlags = new ArrayList<>(this.flags); Collections.addAll(newFlags, toAdd); this.flags = List.copyOf(newFlags); @@ -140,15 +123,12 @@ public Builder addFlags(@NotNull ItemFlag... toAdd) { @CheckReturnValue @Contract(value = "_ -> this", mutates = "this") public Builder appendLore(@NotNull Component... lines) { - Validator.notNull(lines, "lines cannot be null"); - List newLore = new ArrayList<>(this.lore); Collections.addAll(newLore, lines); this.lore = List.copyOf(newLore); return this; } - @NotNull public ItemGui build() { return new ItemGui( this.material, diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGuiSerializer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGuiSerializer.java index 5eacf92..d48aac2 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGuiSerializer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGuiSerializer.java @@ -24,22 +24,22 @@ public void serialize(@NotNull ItemGui item, @NotNull SerializationData data, @N data.add("name", item.name(), Component.class); data.addCollection("lore", item.lore(), Component.class); - var slot = item.slot(); + final var slot = item.slot(); if (slot != null) { data.add("slot", slot, Integer.class); } - var enchantments = item.enchantments(); + final var enchantments = item.enchantments(); if (enchantments != null && !enchantments.isEmpty()) { data.addAsMap("enchantments", item.enchantments(), Enchantment.class, Integer.class); } - var flags = item.flags(); + final var flags = item.flags(); if (flags != null && !flags.isEmpty()) { data.addCollection("flags", flags, ItemFlag.class); } - var permission = item.requiredPermission(); + final var permission = item.requiredPermission(); if (permission != null && !permission.isBlank()) { data.add("permission", permission, String.class); } @@ -47,14 +47,14 @@ public void serialize(@NotNull ItemGui item, @NotNull SerializationData data, @N @Override public ItemGui deserialize(@NotNull DeserializationData data, @NotNull GenericsDeclaration generics) { - var material = data.get("material", Material.class); - var name = data.get("name", Component.class); - var lore = data.getAsList("lore", Component.class); + final var material = data.get("material", Material.class); + final var name = data.get("name", Component.class); + final var lore = data.getAsList("lore", Component.class); - var slot = data.get("slot", Integer.class); - var enchantments = data.getAsMap("enchantments", Enchantment.class, Integer.class); - var flags = data.getAsList("flags", ItemFlag.class); - var permission = data.get("permission", String.class); + final var slot = data.get("slot", Integer.class); + final var enchantments = data.getAsMap("enchantments", Enchantment.class, Integer.class); + final var flags = data.getAsList("flags", ItemFlag.class); + final var permission = data.get("permission", String.class); return new ItemGui( material, diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGuiTransformer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGuiTransformer.java index 50b9954..35b309d 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGuiTransformer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemGuiTransformer.java @@ -1,6 +1,5 @@ package com.github.imdmk.playtime.platform.gui.item; -import com.github.imdmk.playtime.shared.validate.Validator; import dev.triumphteam.gui.builder.item.BaseItemBuilder; import dev.triumphteam.gui.builder.item.ItemBuilder; import dev.triumphteam.gui.components.GuiAction; @@ -12,74 +11,31 @@ import java.util.function.Consumer; -/** - * Stateless utility that converts {@link ItemGui} definitions into Triumph {@link GuiItem}s. - * - *

Thread-safety: Pure transformation; prefer main thread for Bukkit objects.

- */ public final class ItemGuiTransformer { private ItemGuiTransformer() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } - /** - * Creates a {@link GuiItem} with a no-op click handler. - * - * @param item item definition (non-null) - * @return a new {@link GuiItem} instance - * @throws IllegalArgumentException if {@code item} is {@code null} - */ - public static @NotNull GuiItem toGuiItem(@NotNull ItemGui item) { + public static GuiItem toGuiItem(@NotNull ItemGui item) { return toGuiItem(item, (e) -> {}, (b) -> {}); } - /** - * Creates a {@link GuiItem} wiring a {@link GuiAction} click handler. - * - * @param item item (non-null) - * @param onClick click handler (non-null) - * @return a new {@link GuiItem} instance - * @throws IllegalArgumentException if any argument is {@code null} - */ - public static @NotNull GuiItem toGuiItem(@NotNull ItemGui item, @NotNull GuiAction onClick) { + public static GuiItem toGuiItem(@NotNull ItemGui item, @NotNull GuiAction onClick) { return toGuiItem(item, onClick, (b) -> {}); } - /** - * Creates a {@link GuiItem} wiring a standard {@link Consumer} click handler. - * Convenience overload that adapts to Triumph's {@link GuiAction}. - * - * @param item item (non-null) - * @param onClick click handler (non-null) - * @return a new {@link GuiItem} instance - * @throws IllegalArgumentException if any argument is {@code null} - */ - public static @NotNull GuiItem toGuiItem(@NotNull ItemGui item, @NotNull Consumer onClick) { + public static GuiItem toGuiItem(@NotNull ItemGui item, @NotNull Consumer onClick) { return toGuiItem(item, onClick::accept, (b) -> {}); } - /** - * Creates a {@link GuiItem} with handler and optional builder editor. - * - * @param item item (non-null) - * @param onClick click handler (non-null) - * @param builderEditor item builder editor (non-null) - * @return a new {@link GuiItem} instance - * @throws IllegalArgumentException if any argument is {@code null} - */ - public static @NotNull GuiItem toGuiItem( + public static GuiItem toGuiItem( @NotNull ItemGui item, @NotNull GuiAction onClick, @NotNull Consumer> builderEditor ) { - Validator.notNull(item, "item cannot be null"); - Validator.notNull(onClick, "onClick cannot be null"); - Validator.notNull(builderEditor, "builderEditor cannot be null"); - - final Material material = item.material(); - final BaseItemBuilder builder = - material == Material.PLAYER_HEAD ? ItemBuilder.skull() : ItemBuilder.from(material); + final var material = item.material(); + final var builder = material == Material.PLAYER_HEAD ? ItemBuilder.skull() : ItemBuilder.from(material); builder.name(item.name()); builder.lore(item.lore()); diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemVariantPermissionResolver.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemVariantPermissionResolver.java index ce50b59..a6c8051 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemVariantPermissionResolver.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemVariantPermissionResolver.java @@ -1,44 +1,11 @@ package com.github.imdmk.playtime.platform.gui.item; import com.github.imdmk.playtime.platform.gui.render.RenderContext; -import com.github.imdmk.playtime.shared.validate.Validator; import org.bukkit.entity.HumanEntity; import org.jetbrains.annotations.NotNull; -/** - * Resolves which {@link ItemGui} variant should be displayed to a viewer - * based on their permission state. - * - *

This implementation iterates through candidate items in order and returns - * the first one that either: - *

    - *
  • Has no required permission ({@code requiredPermission() == null}), or
  • - *
  • Has a permission that the viewer possesses, as determined by - * {@link RenderContext#permissionEvaluator()}.
  • - *
- * If no candidate matches, a predefined fallback item is returned.

- * - *

Usage: Typically used by GUI renderers to determine which - * item variant to display for users with different roles or permission levels.

- * - *

Thread-safety: This resolver is stateless and thread-safe.

- * - * @see ItemGui - * @see ItemVariantResolver - * @see RenderContext - */ public final class ItemVariantPermissionResolver implements ItemVariantResolver { - /** - * Resolves the first matching {@link ItemGui} variant visible to the given viewer. - * - * @param viewer the player or entity viewing the GUI (non-null) - * @param context current rendering context, providing permission evaluation (non-null) - * @param candidates ordered list of possible item variants (non-null) - * @param fallback default item to return if no candidate matches (non-null) - * @return the first permitted item variant, or {@code fallback} if none are allowed - * @throws NullPointerException if any argument is null - */ @Override public ItemGui resolve( @NotNull HumanEntity viewer, @@ -46,11 +13,6 @@ public ItemGui resolve( @NotNull Iterable candidates, @NotNull ItemGui fallback ) { - Validator.notNull(viewer, "viewer cannot be null"); - Validator.notNull(context, "context cannot be null"); - Validator.notNull(candidates, "candidates cannot be null"); - Validator.notNull(fallback, "fallback cannot be null"); - for (final ItemGui item : candidates) { if (item == null) { continue; diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemVariantResolver.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemVariantResolver.java index 163011d..328a13c 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemVariantResolver.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/item/ItemVariantResolver.java @@ -4,35 +4,8 @@ import org.bukkit.entity.HumanEntity; import org.jetbrains.annotations.NotNull; -/** - * Defines a strategy for selecting which {@link ItemGui} variant - * should be displayed to a specific viewer during GUI rendering. - * - *

Implementations of this interface encapsulate different - * resolution logics — e.g., by permission, by user state, - * by contextual conditions, or by custom business rules.

- * - *

The resolver is typically used within GUI frameworks to decide - * which visual representation of an item (variant) to render for a given player.

- * - *

Thread-safety: Implementations should be stateless and thread-safe.

- * - * @see ItemGui - * @see RenderContext - * @see ItemVariantPermissionResolver - */ public interface ItemVariantResolver { - /** - * Resolves the most appropriate {@link ItemGui} variant to display. - * - * @param viewer the player or entity viewing the GUI (non-null) - * @param context the current rendering context providing permission checks, locale, etc. (non-null) - * @param candidates iterable collection of possible item variants, evaluated in order (non-null) - * @param fallback default item variant to use if none match (non-null) - * @return the resolved item variant, never {@code null} (at least {@code fallback}) - * @throws NullPointerException if any parameter is null - */ ItemGui resolve(@NotNull HumanEntity viewer, @NotNull RenderContext context, @NotNull Iterable candidates, diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/GuiRenderer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/GuiRenderer.java index 2f6fcb8..6015bfa 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/GuiRenderer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/GuiRenderer.java @@ -9,10 +9,6 @@ import java.util.function.Consumer; -/** - * Renders and places {@link ItemGui} into {@link BaseGui} instances. - * Invoke only on the Bukkit main thread. - */ public interface GuiRenderer { @Contract(mutates = "param1") @@ -26,10 +22,6 @@ default void setItem(@NotNull BaseGui gui, setItem(gui, slot, item, context, options, onClick, b -> {}); } - /** - * Sets the item in a specific slot (overwrites existing content). - * Supports per-slot customization via {@code builderEditor}. - */ @Contract(mutates = "param1") void setItem(@NotNull BaseGui gui, int slot, @@ -81,10 +73,6 @@ default void addItem(@NotNull BaseGui gui, addItem(gui, item, context, options, onClick, b -> {}); } - /** - * Adds the item to the next free slot. - * Supports per-slot customization via {@code builderEditor}. - */ @Contract(mutates = "param1") void addItem(@NotNull BaseGui gui, @NotNull ItemGui item, diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/NoPermissionPolicy.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/NoPermissionPolicy.java index ee7cb96..4ce4519 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/NoPermissionPolicy.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/NoPermissionPolicy.java @@ -1,19 +1,6 @@ package com.github.imdmk.playtime.platform.gui.render; -/** - * Defines how a GUI element should behave when the viewer lacks - * the required permission to interact with or view the item. - */ public enum NoPermissionPolicy { - - /** - * The item is completely hidden and not placed in the GUI. - */ HIDE, - - /** - * The item is still visible but interaction is disabled. - * Clicking it will trigger the "onDenied" consumer if provided. - */ DISABLE } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/PermissionEvaluator.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/PermissionEvaluator.java index 2c27ab3..f7e5a3c 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/PermissionEvaluator.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/PermissionEvaluator.java @@ -3,23 +3,8 @@ import org.bukkit.entity.HumanEntity; import org.jetbrains.annotations.NotNull; -/** - * Strategy interface for checking player permissions. - *

- * This abstraction allows GUIs and renderers to remain independent - * from the underlying permission system (e.g. Bukkit, Vault, LuckPerms). - *

- * Implementations should be thread-safe if evaluated asynchronously. - */ @FunctionalInterface public interface PermissionEvaluator { - /** - * Checks whether the given human entity possesses the specified permission. - * - * @param entity the entity being checked (non-null) - * @param permission the permission node (non-null) - * @return {@code true} if the player has the permission; {@code false} otherwise - */ boolean has(@NotNull HumanEntity entity, @NotNull String permission); } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/RenderContext.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/RenderContext.java index 1369aa4..f34a6ef 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/RenderContext.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/RenderContext.java @@ -4,29 +4,12 @@ import org.bukkit.permissions.Permissible; import org.jetbrains.annotations.NotNull; -/** - * Immutable context used during GUI item rendering. - *

- * Encapsulates the viewer and the permission evaluation strategy, - * ensuring renderers remain stateless and easily testable. - *

- * Thread-safety: This record is immutable and thread-safe - * as long as the underlying {@link PermissionEvaluator} implementation is thread-safe. - * - * @param viewer the player for whom the GUI is being rendered - * @param permissionEvaluator the strategy used to check permissions - */ public record RenderContext( @NotNull Player viewer, @NotNull PermissionEvaluator permissionEvaluator ) { - /** - * Creates a default context that checks if player has permission. - * - * @return the default {@link RenderContext} instance - */ - public static @NotNull RenderContext defaultContext(@NotNull Player viewer) { + public static RenderContext defaultContext(@NotNull Player viewer) { return new RenderContext(viewer, Permissible::hasPermission); } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/RenderOptions.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/RenderOptions.java index 03a8d30..ccfaffa 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/RenderOptions.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/RenderOptions.java @@ -5,36 +5,16 @@ import java.util.function.Consumer; -/** - * Rendering options that define how permission handling - * and denied interactions are processed during GUI rendering. - * - * @param policy how to handle items when the viewer lacks permission - * @param onDenied consumer called when a denied item is clicked - *

- * Thread-safety: This record is immutable and thread-safe, - * provided that the supplied {@link Consumer} implementation is thread-safe. - */ public record RenderOptions( @NotNull NoPermissionPolicy policy, @NotNull Consumer onDenied ) { - /** - * Creates a default option that disables unauthorized items silently. - * - * @return the default {@link RenderOptions} instance - */ - public static @NotNull RenderOptions defaultDenySilently() { + public static RenderOptions defaultDenySilently() { return new RenderOptions(NoPermissionPolicy.DISABLE, e -> {}); } - /** - * Creates a default option that hides unauthorized items completely. - * - * @return the default {@link RenderOptions} instance - */ - public static @NotNull RenderOptions defaultHide() { + public static RenderOptions defaultHide() { return new RenderOptions(NoPermissionPolicy.HIDE, e -> {}); } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/TriumphGuiRenderer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/TriumphGuiRenderer.java index 5fbbc70..3ce9477 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/TriumphGuiRenderer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/render/TriumphGuiRenderer.java @@ -2,7 +2,6 @@ import com.github.imdmk.playtime.platform.gui.item.ItemGui; import com.github.imdmk.playtime.platform.gui.item.ItemGuiTransformer; -import com.github.imdmk.playtime.shared.validate.Validator; import dev.triumphteam.gui.builder.item.BaseItemBuilder; import dev.triumphteam.gui.components.GuiAction; import dev.triumphteam.gui.guis.BaseGui; @@ -12,117 +11,64 @@ import org.bukkit.event.inventory.InventoryClickEvent; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.util.function.Consumer; -/** - * Default {@link GuiRenderer} implementation using the Triumph GUI API. - *

- * Responsible for rendering {@link ItemGui} objects into a {@link BaseGui}, - * applying permission policies and wiring click handlers. - * - *

Behavior: - *

    - *
  • If {@link NoPermissionPolicy#HIDE} → item is not rendered (returns {@code null}).
  • - *
  • If {@link NoPermissionPolicy#DISABLE} → click is blocked (cancelled) silently.
  • - *
  • Otherwise → executes provided click handler.
  • - *
- */ public final class TriumphGuiRenderer implements GuiRenderer { - /** - * Creates a new renderer instance. - *

Renderer is stateless and may be safely reused.

- * - * @return new {@link TriumphGuiRenderer} instance - */ public static TriumphGuiRenderer newRenderer() { return new TriumphGuiRenderer(); } - /** - * Places a rendered {@link ItemGui} into the specified GUI slot. - *

If the item should be hidden (policy {@code HIDE}), it will not be placed.

- * - * @param gui target GUI - * @param slot target slot index - * @param item GUI item definition - * @param context render context - * @param options render options - * @param onClick click action to execute if allowed - * @param builderEditor optional builder customization - */ @Override @Contract(mutates = "param1") public void setItem( - @NotNull final BaseGui gui, - final int slot, - @NotNull final ItemGui item, - @NotNull final RenderContext context, - @NotNull final RenderOptions options, - @NotNull final Consumer onClick, - @NotNull final Consumer> builderEditor + @NotNull BaseGui gui, + int slot, + @NotNull ItemGui item, + @NotNull RenderContext context, + @NotNull RenderOptions options, + @NotNull Consumer onClick, + @NotNull Consumer> builderEditor ) { - validateArgs(gui, item, context, options, onClick, builderEditor); - - final GuiItem guiItem = buildGuiItem(item, context, options, onClick, builderEditor); - if (guiItem != null) { - gui.setItem(slot, guiItem); + final GuiItem builtItem = buildGuiItem(item, context, options, onClick, builderEditor); + if (builtItem != null) { + gui.setItem(slot, builtItem); } } - /** - * Adds a rendered {@link ItemGui} to the GUI at the next available position. - *

If the item should be hidden (policy {@code HIDE}), it will not be added.

- * - * @param gui target GUI - * @param item GUI item definition - * @param context render context - * @param options render options - * @param onClick click action to execute if allowed - * @param builderEditor optional builder customization - */ @Override @Contract(mutates = "param1") public void addItem( - @NotNull final BaseGui gui, - @NotNull final ItemGui item, - @NotNull final RenderContext context, - @NotNull final RenderOptions options, - @NotNull final Consumer onClick, - @NotNull final Consumer> builderEditor + @NotNull BaseGui gui, + @NotNull ItemGui item, + @NotNull RenderContext context, + @NotNull RenderOptions options, + @NotNull Consumer onClick, + @NotNull Consumer> builderEditor ) { - validateArgs(gui, item, context, options, onClick, builderEditor); - - final GuiItem guiItem = buildGuiItem(item, context, options, onClick, builderEditor); - if (guiItem != null) { - gui.addItem(guiItem); + final GuiItem builtItem = buildGuiItem(item, context, options, onClick, builderEditor); + if (builtItem != null) { + gui.addItem(builtItem); } } - /** - * Builds a {@link GuiItem} based on the given item definition and context. - *

- * Permission logic: - *

    - *
  • If the viewer lacks permission and policy is {@code HIDE}, returns {@code null}.
  • - *
  • If the viewer lacks permission and policy is {@code DISABLE}, click is blocked silently.
  • - *
- * - * @return a built {@link GuiItem}, or {@code null} if hidden - */ - private @Nullable GuiItem buildGuiItem( - @NotNull final ItemGui item, - @NotNull final RenderContext context, - @NotNull final RenderOptions options, - @NotNull final Consumer onClick, - @NotNull final Consumer> builderEditor + private static GuiItem buildGuiItem( + ItemGui item, + RenderContext context, + RenderOptions options, + Consumer onClick, + Consumer> builderEditor ) { final String requiredPerm = item.requiredPermission(); + final boolean allowedForViewerNow = hasPermission(requiredPerm, context, context.viewer()); + if (!allowedForViewerNow && options.policy() == NoPermissionPolicy.HIDE) { + return null; + } + final GuiAction clickHandler = event -> { - if (!has(requiredPerm, context, event.getWhoClicked())) { + if (!hasPermission(requiredPerm, context, event.getWhoClicked())) { event.setCancelled(true); event.setResult(Event.Result.DENY); options.onDenied().accept(event); @@ -132,46 +78,10 @@ public void addItem( onClick.accept(event); }; - final boolean allowedForViewerNow = has(requiredPerm, context, context.viewer()); - if (!allowedForViewerNow && options.policy() == NoPermissionPolicy.HIDE) { - return null; - } - return ItemGuiTransformer.toGuiItem(item, clickHandler, builderEditor); } - /** - * Checks if the given entity has the required permission. - * - * @param permission permission string or {@code null} - * @param context render context - * @param entity entity to check - * @return {@code true} if allowed, otherwise {@code false} - */ - private static boolean has(@Nullable final String permission, - @NotNull final RenderContext context, - @NotNull final HumanEntity entity) { + private static boolean hasPermission(String permission, RenderContext context, HumanEntity entity) { return permission == null || context.permissionEvaluator().has(entity, permission); } - - /** - * Ensures all arguments are non-null. - * - * @throws NullPointerException if any argument is {@code null} - */ - private static void validateArgs( - final BaseGui gui, - final ItemGui item, - final RenderContext context, - final RenderOptions options, - final Consumer onClick, - final Consumer> builderEditor - ) { - Validator.notNull(gui, "gui cannot be null"); - Validator.notNull(item, "item cannot be null"); - Validator.notNull(context, "context cannot be null"); - Validator.notNull(options, "options cannot be null"); - Validator.notNull(onClick, "onClick cannot be null"); - Validator.notNull(builderEditor, "builderEditor cannot be null"); - } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/AbstractGui.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/AbstractGui.java index 17d6264..eeaad41 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/AbstractGui.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/AbstractGui.java @@ -1,12 +1,9 @@ package com.github.imdmk.playtime.platform.gui.view; -import com.github.imdmk.playtime.platform.gui.config.GuiConfig; import com.github.imdmk.playtime.platform.gui.config.NavigationBarConfig; import com.github.imdmk.playtime.platform.gui.render.GuiRenderer; -import com.github.imdmk.playtime.platform.gui.render.RenderContext; import com.github.imdmk.playtime.platform.gui.render.RenderOptions; import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; -import com.github.imdmk.playtime.shared.validate.Validator; import dev.triumphteam.gui.guis.BaseGui; import org.bukkit.entity.Player; import org.bukkit.event.inventory.InventoryClickEvent; @@ -14,18 +11,6 @@ import java.util.function.Consumer; -/** - * Thin base for GUI implementations. - *

- * Responsibilities: - *

    - *
  • Provide navigation helpers (Next/Previous/Exit),
  • - *
  • Hold shared collaborators: {@link GuiConfig}, {@link TaskScheduler}, - * {@link GuiRenderer}, {@link RenderContext}, {@link RenderOptions}.
  • - *
- * - * Threading: All methods are expected to be called on the Bukkit main thread. - */ public abstract class AbstractGui { protected final NavigationBarConfig config; @@ -35,22 +20,16 @@ public abstract class AbstractGui { private final NavigationBar navigationBar; - /** - * @param config GUI config (visual defaults, nav items, etc.) - * @param taskScheduler scheduler for short, sync GUI updates - * @param renderer renderer that places items and enforces permission policy - * @param renderOptions render options (no-permission policy, onDenied) - */ protected AbstractGui( @NotNull NavigationBarConfig config, @NotNull TaskScheduler taskScheduler, @NotNull GuiRenderer renderer, @NotNull RenderOptions renderOptions ) { - this.config = Validator.notNull(config, "config cannot be null"); - this.scheduler = Validator.notNull(taskScheduler, "taskScheduler cannot be null"); - this.renderer = Validator.notNull(renderer, "renderer cannot be null"); - this.renderOptions = Validator.notNull(renderOptions, "renderOptions cannot be null"); + this.config = config; + this.scheduler = taskScheduler; + this.renderer = renderer; + this.renderOptions = renderOptions; this.navigationBar = new NavigationBar( this.config, @@ -60,44 +39,15 @@ protected AbstractGui( ); } - /** - * Places the "Next" control if the GUI is paginated. - * - * @param gui target GUI - * @param viewer target viewer - */ protected void placeNext(@NotNull BaseGui gui, @NotNull Player viewer) { - Validator.notNull(gui, "gui cannot be null"); - Validator.notNull(viewer, "viewer cannot be null"); navigationBar.setNext(gui, viewer); } - /** - * Places the "Previous" control if the GUI is paginated. - * - * @param gui target GUI - * @param viewer target viewer - */ protected void placePrevious(@NotNull BaseGui gui, @NotNull Player viewer) { - Validator.notNull(gui, "gui cannot be null"); - Validator.notNull(viewer, "viewer cannot be null"); navigationBar.setPrevious(gui, viewer); } - /** - * Places the "Exit" control. - * - * @param gui target GUI - * @param viewer target viewer - * @param exit action to run on click - */ - protected void placeExit( - @NotNull BaseGui gui, - @NotNull Player viewer, - @NotNull Consumer exit) { - Validator.notNull(gui, "gui cannot be null"); - Validator.notNull(exit, "exit cannot be null"); - Validator.notNull(viewer, "viewer cannot be null"); - navigationBar.setExit(gui, viewer, exit); + protected void placeExit(@NotNull BaseGui gui, @NotNull Player viewer, @NotNull Consumer exitAction) { + navigationBar.setExit(gui, viewer, exitAction); } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/GridSlots.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/GridSlots.java index 5558fb9..6efe5a2 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/GridSlots.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/GridSlots.java @@ -1,12 +1,5 @@ package com.github.imdmk.playtime.platform.gui.view; -/** - * Provides predefined slot positions for common GUI navigation controls - * (Next, Previous, Exit) depending on the GUI height (3–6 rows). - *

- * Each constant represents the index of an inventory slot where - * navigation buttons should be placed. - */ final class GridSlots { private static final int ROW_3_NEXT = 25; @@ -25,16 +18,6 @@ final class GridSlots { private static final int ROW_6_PREVIOUS = 46; private static final int ROW_6_EXIT = 49; - private GridSlots() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); - } - - /** - * Returns the inventory slot index for the "Next Page" button. - * - * @param rows number of GUI rows (3–6) - * @return slot index for the next-page control - */ static int next(int rows) { return switch (rows) { case 3 -> ROW_3_NEXT; @@ -45,12 +28,6 @@ static int next(int rows) { }; } - /** - * Returns the inventory slot index for the "Previous Page" button. - * - * @param rows number of GUI rows (3–6) - * @return slot index for the previous-page control - */ static int previous(int rows) { return switch (rows) { case 3 -> ROW_3_PREVIOUS; @@ -61,12 +38,6 @@ static int previous(int rows) { }; } - /** - * Returns the inventory slot index for the "Exit" button. - * - * @param rows number of GUI rows (3–6) - * @return slot index for the exit control - */ static int exit(int rows) { return switch (rows) { case 3 -> ROW_3_EXIT; @@ -76,4 +47,8 @@ static int exit(int rows) { default -> throw new IllegalArgumentException("Unsupported rows for EXIT: " + rows); }; } + + GridSlots() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); + } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/GuiOpener.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/GuiOpener.java index 4670266..f7d30f2 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/GuiOpener.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/GuiOpener.java @@ -1,145 +1,53 @@ package com.github.imdmk.playtime.platform.gui.view; +import com.github.imdmk.playtime.injector.annotations.Service; import com.github.imdmk.playtime.platform.gui.GuiRegistry; -import com.github.imdmk.playtime.platform.gui.IdentifiableGui; import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; -import com.github.imdmk.playtime.shared.validate.Validator; -import dev.triumphteam.gui.guis.BaseGui; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import org.panda_lang.utilities.inject.annotations.Inject; -/** - * Opens GUIs by id or class on the Bukkit main thread. - * - *

Responsibilities:

- *
    - *
  • Lookup GUIs via {@link GuiRegistry},
  • - *
  • Invoke {@code BaseGui#open(Player)} on the main thread using {@link TaskScheduler}.
  • - *
- * - * Thread-safety: Safe to call from any thread. Actual GUI operations are marshalled to the main thread. - */ +@Service public final class GuiOpener { private final GuiRegistry registry; - private final TaskScheduler taskScheduler; + private final TaskScheduler scheduler; @Inject - public GuiOpener(@NotNull GuiRegistry registry, @NotNull TaskScheduler taskScheduler) { - this.registry = Validator.notNull(registry, "registry cannot be null"); - this.taskScheduler = Validator.notNull(taskScheduler, "taskScheduler cannot be null"); + public GuiOpener( + GuiRegistry registry, + TaskScheduler scheduler + ) { + this.registry = registry; + this.scheduler = scheduler; } - /** - * Opens a non-parameterized GUI by its concrete class. - * - * @throws IllegalArgumentException if GUI is not registered or not a {@link SimpleGui} - */ public void open( - @NotNull Class type, - @NotNull Player viewer) { - Validator.notNull(type, "type cannot be null"); - Validator.notNull(viewer, "viewer cannot be null"); - - IdentifiableGui gui = require(type); - if (!(gui instanceof SimpleGui simpleGui)) { - throw wrongType(type.getName(), gui, "SimpleGui"); - } - - BaseGui baseGui = simpleGui.createGui(); - simpleGui.prepareItems(baseGui, viewer); - taskScheduler.runSync(() -> baseGui.open(viewer)); + @NotNull Class> type, + @NotNull Player viewer + ) { + final OpenableGui gui = require(type); + gui.open(viewer, scheduler, null); } - /** - * Opens a parameterized GUI by its concrete class. - * - * @throws IllegalArgumentException if GUI is not registered or not a {@link ParameterizedGui} - */ - @SuppressWarnings("unchecked") - public void open(@NotNull Class> type, - @NotNull Player viewer, - @NotNull T parameter) { - Validator.notNull(type, "type cannot be null"); - Validator.notNull(viewer, "viewer cannot be null"); - Validator.notNull(parameter, "parameter cannot be null"); - - IdentifiableGui gui = require(type); - if (!(gui instanceof ParameterizedGui paramGui)) { - throw wrongType(type.getName(), gui, "ParameterizedGui"); - } - - ParameterizedGui typed = (ParameterizedGui) paramGui; - BaseGui baseGui = typed.createGui(viewer, parameter); - typed.prepareItems(baseGui, viewer, parameter); - taskScheduler.runSync(() -> baseGui.open(viewer)); - } - /** - * Opens a non-parameterized GUI by id for the given player. - * - * @throws IllegalArgumentException if id is unknown or GUI is not a {@link SimpleGui} - */ - public void open( - @NotNull String id, - @NotNull Player viewer) { - Validator.notNull(id, "id cannot be null"); - Validator.notNull(viewer, "viewer cannot be null"); - - IdentifiableGui gui = require(id); - if (!(gui instanceof SimpleGui simpleGui)) { - throw wrongType(id, gui, "SimpleGui"); - } - - BaseGui baseGui = simpleGui.createGui(); - simpleGui.prepareItems(baseGui, viewer); - taskScheduler.runSync(() -> baseGui.open(viewer)); - } - - /** - * Opens a parameterized GUI by id for the given player. - * - * @throws IllegalArgumentException if id is unknown or GUI is not a {@link ParameterizedGui} - */ - @SuppressWarnings("unchecked") public void open( - @NotNull String id, + @NotNull Class> type, @NotNull Player viewer, - @NotNull T parameter) { - Validator.notNull(id, "id cannot be null"); - Validator.notNull(viewer, "viewer cannot be null"); - Validator.notNull(parameter, "parameter cannot be null"); - - IdentifiableGui gui = require(id); - if (!(gui instanceof ParameterizedGui paramGui)) { - throw wrongType(id, gui, "ParameterizedGui"); - } - - ParameterizedGui typed = (ParameterizedGui) paramGui; - BaseGui baseGui = typed.createGui(viewer, parameter); - typed.prepareItems(baseGui, viewer, parameter); - taskScheduler.runSync(() -> baseGui.open(viewer)); + @NotNull T parameter + ) { + final OpenableGui gui = require(type); + gui.open(viewer, scheduler, parameter); } - private @NotNull IdentifiableGui require(@NotNull String id) { - IdentifiableGui gui = registry.getById(id); + private

OpenableGui

require(Class> type) { + final OpenableGui

gui = registry.getByClass(type); if (gui == null) { - throw new IllegalArgumentException("No GUI registered under id '" + id + "'"); + throw new IllegalArgumentException( + "No GUI registered for class '" + type.getName() + "'" + ); } - return gui; - } - private @NotNull IdentifiableGui require(@NotNull Class type) { - IdentifiableGui gui = registry.getByClass(type); - if (gui == null) { - throw new IllegalArgumentException("No GUI registered for class '" + type.getName() + "'"); - } return gui; } - private static IllegalArgumentException wrongType(String key, IdentifiableGui gui, String expected) { - return new IllegalArgumentException( - "GUI '" + key + "' is not a " + expected + " (actual=" + gui.getClass().getSimpleName() + ")" - ); - } -} \ No newline at end of file +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/NavigationBar.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/NavigationBar.java index 843cd10..9d330b4 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/NavigationBar.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/NavigationBar.java @@ -5,7 +5,6 @@ import com.github.imdmk.playtime.platform.gui.render.RenderContext; import com.github.imdmk.playtime.platform.gui.render.RenderOptions; import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; -import com.github.imdmk.playtime.shared.validate.Validator; import dev.triumphteam.gui.guis.BaseGui; import dev.triumphteam.gui.guis.PaginatedGui; import org.bukkit.entity.Player; @@ -15,116 +14,75 @@ import java.time.Duration; import java.util.function.Consumer; -/** - * Places navigation controls (Next, Previous, Exit) into Triumph GUIs. - *

- * Responsibilities: - *

    - *
  • Compute target slots via {@link GridSlots},
  • - *
  • Delegate permission/policy enforcement to {@link GuiRenderer},
  • - *
  • Provide short-lived feedback (e.g., "no next/previous") and restore original items.
  • - *
- * - * Threading: All methods are expected to be called on the Bukkit main thread. - * The class is stateless w.r.t. rendering; it holds only injected collaborators. - */ final class NavigationBar { - private static final Duration RESTORE_DELAY = Duration.ofSeconds(1); + private static final Duration DELAY = Duration.ofSeconds(1); private final NavigationBarConfig config; private final TaskScheduler scheduler; private final GuiRenderer renderer; private final RenderOptions renderOptions; - /** - * @param config navigation bar config (items, etc.) - * @param scheduler scheduler for short delayed updates - * @param renderer GUI renderer enforcing permission policy - * @param renderOptions render options (no-permission policy, onDenied) - */ NavigationBar( @NotNull NavigationBarConfig config, @NotNull TaskScheduler scheduler, @NotNull GuiRenderer renderer, @NotNull RenderOptions renderOptions ) { - this.config = Validator.notNull(config, "config cannot be null"); - this.renderer = Validator.notNull(renderer, "renderer cannot be null"); - this.scheduler = Validator.notNull(scheduler, "scheduler cannot be null"); - this.renderOptions = Validator.notNull(renderOptions, "renderOptions cannot be null"); + this.config = config; + this.renderer = renderer; + this.scheduler = scheduler; + this.renderOptions = renderOptions; } - /** - * Places the "Next page" button if {@code gui} is paginated. - */ void setNext(@NotNull BaseGui gui, @NotNull Player viewer) { - Validator.notNull(gui, "gui cannot be null"); - Validator.notNull(viewer, "viewer cannot be null"); - if (!(gui instanceof PaginatedGui paginated)) { return; } - final var context = RenderContext.defaultContext(viewer); - final var slot = GridSlots.next(gui.getRows()); + final RenderContext context = RenderContext.defaultContext(viewer); + final int slot = GridSlots.next(gui.getRows()); final Consumer onClick = event -> { if (!paginated.next()) { renderer.setItem(gui, event.getSlot(), config.noNextItem, context, renderOptions, this::noop); - restoreLater(() -> setNext(gui, viewer)); + runLater(() -> setNext(gui, viewer)); } }; renderer.setItem(gui, slot, config.nextItem, context, renderOptions, onClick); } - /** - * Places the "Previous page" button if {@code gui} is paginated. - */ void setPrevious(@NotNull BaseGui gui, @NotNull Player viewer) { - Validator.notNull(gui, "gui cannot be null"); - Validator.notNull(viewer, "viewer cannot be null"); - if (!(gui instanceof PaginatedGui paginated)) { return; } - final var context = RenderContext.defaultContext(viewer); - final var slot = GridSlots.previous(gui.getRows()); + final RenderContext context = RenderContext.defaultContext(viewer); + final int slot = GridSlots.previous(gui.getRows()); final Consumer onClick = event -> { if (!paginated.previous()) { renderer.setItem(gui, event.getSlot(), config.noPreviousItem, context, renderOptions, this::noop); - restoreLater(() -> setPrevious(gui, viewer)); + runLater(() -> setPrevious(gui, viewer)); } }; renderer.setItem(gui, slot, config.previousItem, context, renderOptions, onClick); } - /** - * Places the "Exit" button which triggers the provided action. - */ void setExit( @NotNull BaseGui gui, @NotNull Player viewer, - @NotNull Consumer exit) { - Validator.notNull(gui, "gui cannot be null"); - Validator.notNull(viewer, "viewer cannot be null"); - Validator.notNull(exit, "exit cannot be null"); - - final var context = RenderContext.defaultContext(viewer); - final var slot = GridSlots.exit(gui.getRows()); + @NotNull Consumer exit + ) { + final RenderContext context = RenderContext.defaultContext(viewer); + final int slot = GridSlots.exit(gui.getRows()); renderer.setItem(gui, slot, config.exitItem, context, renderOptions, exit); } - /** - * Schedules a short delayed restore action (e.g., after showing "no next/previous"). - */ - private void restoreLater(@NotNull Runnable restoreAction) { - Validator.notNull(restoreAction, "restoreAction cannot be null"); - scheduler.runLaterSync(restoreAction, RESTORE_DELAY); + private void runLater(Runnable runnable) { + scheduler.runLaterSync(runnable, DELAY); } private void noop(@NotNull InventoryClickEvent e) { diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/OpenableGui.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/OpenableGui.java new file mode 100644 index 0000000..79bdb88 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/OpenableGui.java @@ -0,0 +1,16 @@ +package com.github.imdmk.playtime.platform.gui.view; + +import com.github.imdmk.playtime.platform.gui.IdentifiableGui; +import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +public interface OpenableGui

extends IdentifiableGui { + + void open( + @NotNull Player viewer, + @NotNull TaskScheduler scheduler, + P parameter + ); +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/ParameterizedGui.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/ParameterizedGui.java index 8a8c924..3d22289 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/ParameterizedGui.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/ParameterizedGui.java @@ -1,33 +1,29 @@ package com.github.imdmk.playtime.platform.gui.view; -import com.github.imdmk.playtime.platform.gui.IdentifiableGui; +import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; import dev.triumphteam.gui.guis.BaseGui; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -/** - * Represents a generic GUI that requires a parameter to be initialized and populated. - * Acts as a template for all GUIs that are parameterized, defining a default open lifecycle. - * - * @param the type of parameter used for populating the GUI - */ -public interface ParameterizedGui extends IdentifiableGui { +public interface ParameterizedGui extends OpenableGui { + + @Override + default void open( + @NotNull Player viewer, + @NotNull TaskScheduler scheduler, + @NotNull T parameter + ) { + final BaseGui gui = createGui(viewer, parameter); + prepareItems(gui, viewer, parameter); + scheduler.runSync(() -> gui.open(viewer)); + } - /** - * Creates a new instance of the GUI. - * - * @param viewer the player viewing the GUI - * @param parameter the parameter used to customize the GUI - * @return the initialized {@link BaseGui} instance - */ BaseGui createGui(@NotNull Player viewer, @NotNull T parameter); - /** - * Prepares and populates the GUI with core content based on the parameter. - * - * @param gui the GUI to populate - * @param viewer the player viewing the GUI - * @param parameter the context parameter - */ - void prepareItems(@NotNull BaseGui gui, @NotNull Player viewer, @NotNull T parameter); + void prepareItems( + @NotNull BaseGui gui, + @NotNull Player viewer, + @NotNull T parameter + ); } + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/SimpleGui.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/SimpleGui.java index fa235d1..37b2e30 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/SimpleGui.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/gui/view/SimpleGui.java @@ -1,28 +1,27 @@ package com.github.imdmk.playtime.platform.gui.view; -import com.github.imdmk.playtime.platform.gui.IdentifiableGui; +import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; import dev.triumphteam.gui.guis.BaseGui; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -/** - * Represents a simple GUI that does not require a parameter to be created or populated. - * Defines a standard lifecycle for opening such GUIs. - */ -public interface SimpleGui extends IdentifiableGui { +public interface SimpleGui extends OpenableGui { - /** - * Creates a new instance of the GUI. - * - * @return the initialized {@link BaseGui} instance - */ - BaseGui createGui(); + @Override + default void open( + @NotNull Player viewer, + @NotNull TaskScheduler scheduler, + Void unused + ) { + final BaseGui gui = createGui(viewer); + prepareItems(gui, viewer); + scheduler.runSync(() -> gui.open(viewer)); + } - /** - * Prepares and populates the GUI with its core content. - * - * @param gui the GUI to populate - * @param viewer the player viewing the GUI - */ - void prepareItems(@NotNull BaseGui gui, @NotNull Player viewer); + BaseGui createGui(@NotNull Player viewer); + + void prepareItems( + @NotNull BaseGui gui, + @NotNull Player viewer + ); } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/identity/IdentityCache.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/identity/IdentityCache.java new file mode 100644 index 0000000..b362eec --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/identity/IdentityCache.java @@ -0,0 +1,56 @@ +package com.github.imdmk.playtime.platform.identity; + +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.injector.subscriber.Subscribe; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeShutdownEvent; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Service(priority = ComponentPriority.LOWEST) +public final class IdentityCache { + + private final Map nameToUuid = new ConcurrentHashMap<>(); + private final Map uuidToName = new ConcurrentHashMap<>(); + + public void update(@NotNull Player player) { + nameToUuid.put(player.getName(), player.getUniqueId()); + uuidToName.put(player.getUniqueId(), player.getName()); + } + + public void remove(@NotNull Player player) { + nameToUuid.remove(player.getName()); + uuidToName.remove(player.getUniqueId()); + } + + public Optional getUuidByName(@NotNull String name) { + return Optional.ofNullable(nameToUuid.get(name)); + } + + public Optional getNameByUuid(@NotNull UUID playerId) { + return Optional.ofNullable(uuidToName.get(playerId)); + } + + @Unmodifiable + public Set getPlayerUuids() { + return Set.copyOf(uuidToName.keySet()); + } + + @Unmodifiable + public Set getPlayerNames() { + return Set.copyOf(nameToUuid.keySet()); + } + + @Subscribe(event = PlayTimeShutdownEvent.class) + private void shutdown() { + nameToUuid.clear(); + } +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/identity/IdentityController.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/identity/IdentityController.java new file mode 100644 index 0000000..9de6765 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/identity/IdentityController.java @@ -0,0 +1,31 @@ +package com.github.imdmk.playtime.platform.identity; + +import com.github.imdmk.playtime.injector.annotations.Controller; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +@Controller +final class IdentityController implements Listener { + + private final IdentityCache cache; + + @Inject + IdentityController(@NotNull IdentityCache cache) { + this.cache = cache; + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + cache.update(event.getPlayer()); + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + cache.remove(event.getPlayer()); + } +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/identity/IdentityService.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/identity/IdentityService.java new file mode 100644 index 0000000..73240ff --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/identity/IdentityService.java @@ -0,0 +1,32 @@ +package com.github.imdmk.playtime.platform.identity; + +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; +import java.util.UUID; + +@Service(priority = ComponentPriority.LOW) +public final class IdentityService { + + private static final String UNKNOWN_PLAYER_NAME = "Unknown"; + + private final Server server; + private final IdentityCache cache; + + public IdentityService(@NotNull Server server, @NotNull IdentityCache cache) { + this.server = server; + this.cache = cache; + } + + public String resolvePlayerName(@NotNull UUID playerId) { + final Optional cached = cache.getNameByUuid(playerId); + return cached.orElseGet(() -> Optional.ofNullable(server.getPlayer(playerId)) + .map(Player::getName) + .orElse(UNKNOWN_PLAYER_NAME)); + + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/LiteCommandsConfigurer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/LiteCommandsConfigurer.java new file mode 100644 index 0000000..103a298 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/LiteCommandsConfigurer.java @@ -0,0 +1,50 @@ +package com.github.imdmk.playtime.platform.litecommands; + +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.injector.subscriber.Subscribe; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeInitializeEvent; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeShutdownEvent; +import dev.rollczi.litecommands.LiteCommands; +import dev.rollczi.litecommands.LiteCommandsBuilder; +import dev.rollczi.litecommands.bukkit.LiteBukkitFactory; +import org.bukkit.Server; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +@Service(priority = ComponentPriority.LOWEST) +public final class LiteCommandsConfigurer { + + private static final String FALLBACK_PREFIX = "AdvancedPlayTime"; + + private final LiteCommandsBuilder builder; + private LiteCommands liteCommands; + + @Inject + LiteCommandsConfigurer(@NotNull Plugin plugin, @NotNull Server server) { + this.builder = LiteBukkitFactory.builder(FALLBACK_PREFIX, plugin, server); + } + + public LiteCommandsBuilder builder() { + return builder; + } + + public LiteCommands liteCommands() { + if (liteCommands == null) { + throw new IllegalStateException("LiteCommands not initialized yet"); + } + return liteCommands; + } + + @Subscribe(event = PlayTimeInitializeEvent.class) + private void initialize() { + this.liteCommands = builder.build(); + } + + @Subscribe(event = PlayTimeShutdownEvent.class) + private void shutdown() { + liteCommands.unregister(); + } +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/NoticeResultHandlerImpl.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/NoticeResultHandlerImpl.java deleted file mode 100644 index 0890e1f..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/NoticeResultHandlerImpl.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.imdmk.playtime.platform.litecommands; - -import com.eternalcode.multification.notice.Notice; -import com.github.imdmk.playtime.message.MessageService; -import com.github.imdmk.playtime.shared.validate.Validator; -import dev.rollczi.litecommands.handler.result.ResultHandler; -import dev.rollczi.litecommands.handler.result.ResultHandlerChain; -import dev.rollczi.litecommands.invocation.Invocation; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.NotNull; - -public final class NoticeResultHandlerImpl implements ResultHandler { - - private final MessageService messageService; - - public NoticeResultHandlerImpl(@NotNull MessageService messageService) { - this.messageService = Validator.notNull(messageService, "messageService cannot be null"); - } - - @Override - public void handle(Invocation invocation, Notice notice, ResultHandlerChain chain) { - messageService.send(invocation.sender(), n -> notice); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/argument/UUIDArgumentResolver.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/argument/UUIDArgumentResolver.java new file mode 100644 index 0000000..850eaba --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/argument/UUIDArgumentResolver.java @@ -0,0 +1,41 @@ +package com.github.imdmk.playtime.platform.litecommands.argument; + +import com.github.imdmk.playtime.injector.annotations.lite.LiteArgument; +import com.github.imdmk.playtime.platform.identity.IdentityCache; +import com.github.imdmk.playtime.shared.message.MessageConfig; +import dev.rollczi.litecommands.argument.Argument; +import dev.rollczi.litecommands.argument.parser.ParseResult; +import dev.rollczi.litecommands.argument.resolver.ArgumentResolver; +import dev.rollczi.litecommands.invocation.Invocation; +import dev.rollczi.litecommands.suggestion.SuggestionContext; +import dev.rollczi.litecommands.suggestion.SuggestionResult; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +import java.util.UUID; + +@LiteArgument(type = UUID.class) +class UUIDArgumentResolver extends ArgumentResolver { + + private final IdentityCache cache; + private final MessageConfig messageConfig; + + @Inject + UUIDArgumentResolver(@NotNull IdentityCache cache, @NotNull MessageConfig messageConfig) { + this.cache = cache; + this.messageConfig = messageConfig; + } + + @Override + protected ParseResult parse(Invocation invocation, Argument context, String argument) { + return cache.getUuidByName(argument) + .map(ParseResult::success) + .orElse(ParseResult.failure(messageConfig.playerNotFound)); + } + + @Override + public SuggestionResult suggest(Invocation invocation, Argument argument, SuggestionContext context) { + return SuggestionResult.of(cache.getPlayerNames()); + } +} \ No newline at end of file diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/InvalidUsageHandlerImpl.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/handler/InvalidUsageHandlerImpl.java similarity index 76% rename from playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/InvalidUsageHandlerImpl.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/handler/InvalidUsageHandlerImpl.java index f454964..3579f1c 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/InvalidUsageHandlerImpl.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/handler/InvalidUsageHandlerImpl.java @@ -1,7 +1,7 @@ -package com.github.imdmk.playtime.platform.litecommands; +package com.github.imdmk.playtime.platform.litecommands.handler; -import com.github.imdmk.playtime.message.MessageService; -import com.github.imdmk.playtime.shared.validate.Validator; +import com.github.imdmk.playtime.injector.annotations.lite.LiteHandler; +import com.github.imdmk.playtime.shared.message.MessageService; import dev.rollczi.litecommands.handler.result.ResultHandlerChain; import dev.rollczi.litecommands.invalidusage.InvalidUsage; import dev.rollczi.litecommands.invalidusage.InvalidUsageHandler; @@ -10,12 +10,13 @@ import org.bukkit.command.CommandSender; import org.jetbrains.annotations.NotNull; -public final class InvalidUsageHandlerImpl implements InvalidUsageHandler { +@LiteHandler(value = CommandSender.class) +final class InvalidUsageHandlerImpl implements InvalidUsageHandler { private final MessageService messageService; - public InvalidUsageHandlerImpl(@NotNull MessageService messageService) { - this.messageService = Validator.notNull(messageService, "messageService cannot be null"); + InvalidUsageHandlerImpl(@NotNull MessageService messageService) { + this.messageService = messageService; } @Override diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/MissingPermissionsHandlerImpl.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/handler/MissingPermissionsHandlerImpl.java similarity index 63% rename from playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/MissingPermissionsHandlerImpl.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/handler/MissingPermissionsHandlerImpl.java index 2ac0cd8..51210a0 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/MissingPermissionsHandlerImpl.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/handler/MissingPermissionsHandlerImpl.java @@ -1,7 +1,7 @@ -package com.github.imdmk.playtime.platform.litecommands; +package com.github.imdmk.playtime.platform.litecommands.handler; -import com.github.imdmk.playtime.message.MessageService; -import com.github.imdmk.playtime.shared.validate.Validator; +import com.github.imdmk.playtime.injector.annotations.lite.LiteHandler; +import com.github.imdmk.playtime.shared.message.MessageService; import dev.rollczi.litecommands.handler.result.ResultHandlerChain; import dev.rollczi.litecommands.invocation.Invocation; import dev.rollczi.litecommands.permission.MissingPermissions; @@ -9,12 +9,13 @@ import org.bukkit.command.CommandSender; import org.jetbrains.annotations.NotNull; -public final class MissingPermissionsHandlerImpl implements MissingPermissionsHandler { +@LiteHandler(value = CommandSender.class) +final class MissingPermissionsHandlerImpl implements MissingPermissionsHandler { private final MessageService messageService; - public MissingPermissionsHandlerImpl(@NotNull MessageService messageService) { - this.messageService = Validator.notNull(messageService, "messageService cannot be null"); + MissingPermissionsHandlerImpl(@NotNull MessageService messageService) { + this.messageService = messageService; } @Override diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/handler/NoticeResultHandlerImpl.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/handler/NoticeResultHandlerImpl.java new file mode 100644 index 0000000..83d19be --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/litecommands/handler/NoticeResultHandlerImpl.java @@ -0,0 +1,28 @@ +package com.github.imdmk.playtime.platform.litecommands.handler; + +import com.eternalcode.multification.notice.Notice; +import com.github.imdmk.playtime.injector.annotations.lite.LiteHandler; +import com.github.imdmk.playtime.shared.message.MessageService; +import dev.rollczi.litecommands.handler.result.ResultHandler; +import dev.rollczi.litecommands.handler.result.ResultHandlerChain; +import dev.rollczi.litecommands.invocation.Invocation; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.NotNull; + +@LiteHandler(value = Notice.class) +final class NoticeResultHandlerImpl implements ResultHandler { + + private final MessageService messageService; + + NoticeResultHandlerImpl(@NotNull MessageService messageService) { + this.messageService = messageService; + } + + @Override + public void handle(Invocation invocation, Notice notice, ResultHandlerChain chain) { + messageService.create() + .viewer(invocation.sender()) + .notice(n -> notice) + .send(); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/logger/BukkitPluginLogger.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/logger/BukkitPluginLogger.java index 5623ca7..ca28657 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/logger/BukkitPluginLogger.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/logger/BukkitPluginLogger.java @@ -1,56 +1,20 @@ package com.github.imdmk.playtime.platform.logger; -import com.github.imdmk.playtime.shared.validate.Validator; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; import java.util.Locale; import java.util.logging.Level; import java.util.logging.Logger; -/** - * Bukkit-specific implementation of {@link PluginLogger} delegating to a standard - * {@link java.util.logging.Logger} obtained from a Bukkit {@link Plugin}. - * - *

This class provides formatted and structured logging methods for common log levels - * (INFO, WARNING, DEBUG, SEVERE) with support for formatted messages and throwable logging.

- * - *

Design notes:

- *
    - *
  • Acts as a lightweight adapter to bridge the internal plugin logging interface with Bukkit’s logger.
  • - *
  • Formatting uses {@link String#format(Locale, String, Object...)} with {@link Locale#ROOT} to ensure locale safety.
  • - *
  • Supports overloaded methods for flexible log message creation, including formatted and exception-based variants.
  • - *
- * - *

Thread-safety: Delegates to the underlying {@link Logger}, which is thread-safe for concurrent use.

- * - * @see PluginLogger - * @see Plugin#getLogger() - * @see Logger - */ public final class BukkitPluginLogger implements PluginLogger { - /** Backing {@link java.util.logging.Logger} provided by Bukkit. */ private final Logger logger; - /** - * Creates a new {@code BukkitPluginLogger} wrapping an existing {@link Logger}. - * - * @param logger non-null backing logger instance - * @throws NullPointerException if {@code logger} is null - */ - public BukkitPluginLogger(@NotNull Logger logger) { - this.logger = Validator.notNull(logger, "logger cannot be null"); - } - - /** - * Creates a new {@code BukkitPluginLogger} bound to the given Bukkit {@link Plugin}. - * - * @param plugin non-null Bukkit plugin instance - * @throws NullPointerException if {@code plugin} is null - */ + @Inject public BukkitPluginLogger(@NotNull Plugin plugin) { - this(plugin.getLogger()); + this.logger = plugin.getLogger(); } @Override @@ -108,17 +72,7 @@ public void error(@NotNull Throwable throwable, @NotNull String message, @NotNul logger.log(Level.SEVERE, format(message, args), throwable); } - /** - * Formats a message using {@link String#format(Locale, String, Object...)} with {@link Locale#ROOT}. - * - * @param message format string (non-null) - * @param args format arguments (non-null) - * @return formatted message - * @throws NullPointerException if {@code message} or {@code args} is null - */ - private String format(@NotNull String message, @NotNull Object... args) { - Validator.notNull(message, "message cannot be null"); - Validator.notNull(args, "args cannot be null"); + private String format(String message, Object... args) { return String.format(Locale.ROOT, message, args); } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/logger/PluginLogger.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/logger/PluginLogger.java index bbaafa1..e0cbf49 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/logger/PluginLogger.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/logger/PluginLogger.java @@ -3,115 +3,20 @@ import org.intellij.lang.annotations.PrintFormat; import org.jetbrains.annotations.NotNull; -/** - * Unified logging abstraction for the PlayTime plugin environment. - * - *

This interface defines a consistent logging API decoupled from the underlying - * logging backend (e.g., Bukkit’s {@link java.util.logging.Logger}, SLF4J, or a custom logger). - * It provides structured, formatted, and throwable-aware logging across multiple log levels.

- * - *

Design goals:

- *
    - *
  • Consistent logging interface across all plugin components.
  • - *
  • Support for message formatting with {@link String#format} syntax.
  • - *
  • Convenient overloads for attaching {@link Throwable}s and stack traces.
  • - *
  • Simple to implement for different backends (e.g., {@link BukkitPluginLogger}).
  • - *
- * - *

Thread-safety: Implementations are expected to be thread-safe and safe - * for concurrent use across async or scheduled tasks.

- * - * @see BukkitPluginLogger - * @see java.util.logging.Logger - */ public interface PluginLogger { - /** - * Logs a general informational message. - * - * @param message message to log (non-null) - */ void info(@NotNull String message); - - /** - * Logs a formatted informational message. - * - * @param message format string using {@link String#format} syntax (non-null) - * @param args arguments to format (non-null) - */ void info(@NotNull @PrintFormat String message, @NotNull Object... args); - /** - * Logs a warning message, indicating a non-fatal issue. - * - * @param message warning message (non-null) - */ void warn(@NotNull String message); - - /** - * Logs a warning caused by a {@link Throwable}, typically without rethrowing it. - * - * @param throwable the exception or error (non-null) - */ - void warn(@NotNull Throwable throwable); - - /** - * Logs a formatted warning message with an associated {@link Throwable}. - * - * @param throwable cause of the warning (non-null) - * @param message format string (non-null) - * @param args format arguments (non-null) - */ - void warn(@NotNull Throwable throwable, - @NotNull @PrintFormat String message, - @NotNull Object... args); - - /** - * Logs a formatted warning message. - * - * @param message format string (non-null) - * @param args format arguments (non-null) - */ void warn(@NotNull @PrintFormat String message, @NotNull Object... args); + void warn(@NotNull Throwable throwable); + void warn(@NotNull Throwable throwable, @NotNull @PrintFormat String message, @NotNull Object... args); - /** - * Logs an error with a throwable stack trace. - * - * @param throwable exception or error to log (non-null) - */ - void error(@NotNull Throwable throwable); - - /** - * Logs an error message at the highest severity level. - * - * @param message message to log (non-null) - */ void error(@NotNull String message); - - /** - * Logs a formatted error message. - * - * @param message format string (non-null) - * @param args format arguments (non-null) - */ void error(@NotNull @PrintFormat String message, @NotNull Object... args); - - /** - * Logs an error message with the given {@link Throwable}. - * - * @param throwable cause of the error (non-null) - * @param message unformatted message text (non-null) - */ + void error(@NotNull Throwable throwable); + void error(@NotNull Throwable throwable, @NotNull @PrintFormat String message, @NotNull Object... args); void error(@NotNull Throwable throwable, @NotNull String message); - /** - * Logs a formatted error message with the given {@link Throwable}. - * - * @param throwable cause of the error (non-null) - * @param message format string (non-null) - * @param args format arguments (non-null) - */ - void error(@NotNull Throwable throwable, - @NotNull @PrintFormat String message, - @NotNull Object... args); } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/metrics/BMetricsService.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/metrics/BMetricsService.java new file mode 100644 index 0000000..8dfb90b --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/metrics/BMetricsService.java @@ -0,0 +1,26 @@ +package com.github.imdmk.playtime.platform.metrics; + +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.injector.subscriber.Subscribe; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeShutdownEvent; +import org.bstats.bukkit.Metrics; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +@Service +final class BMetricsService { + + private static final int METRICS_ID = 19362; + private final Metrics metrics; + + @Inject + BMetricsService(@NotNull Plugin plugin) { + this.metrics = new Metrics(plugin, METRICS_ID); + } + + @Subscribe(event = PlayTimeShutdownEvent.class) + private void shutdown() { + metrics.shutdown(); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/DisabledPlaceholderRegistry.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/DisabledPlaceholderRegistry.java new file mode 100644 index 0000000..182d54a --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/DisabledPlaceholderRegistry.java @@ -0,0 +1,15 @@ +package com.github.imdmk.playtime.platform.placeholder; + +import org.jetbrains.annotations.NotNull; + +final class DisabledPlaceholderRegistry implements PlaceholderRegistry { + + @Override + public void register(@NotNull PluginPlaceholder placeholder) {} + + @Override + public void unregister(@NotNull PluginPlaceholder placeholder) {} + + @Override + public void unregisterAll() {} +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PapiPlaceholderRegistry.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PapiPlaceholderRegistry.java new file mode 100644 index 0000000..8d8f138 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PapiPlaceholderRegistry.java @@ -0,0 +1,104 @@ +package com.github.imdmk.playtime.platform.placeholder; + +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +final class PapiPlaceholderRegistry implements PlaceholderRegistry { + + private final Plugin plugin; + private final Map expansions = new HashMap<>(); + + PapiPlaceholderRegistry(@NotNull Plugin plugin) { + this.plugin = plugin; + } + + @Override + public void register(@NotNull PluginPlaceholder placeholder) { + final String id = normalizeId(placeholder.identifier()); + if (expansions.containsKey(id)) { + throw new IllegalStateException("Placeholder with id " + id + " is already registered!"); + } + + final PlaceholderExpansion expansion = new PlaceholderExpansionAdapter(plugin, placeholder); + if (expansion.register()) { + expansions.put(id, expansion); + } + } + + @Override + public void unregister(@NotNull PluginPlaceholder placeholder) { + final String id = normalizeId(placeholder.identifier()); + + final PlaceholderExpansion expansion = expansions.remove(id); + if (expansion != null) { + expansion.unregister(); + } + } + + @Override + public void unregisterAll() { + for (final PlaceholderExpansion expansion : expansions.values()) { + expansion.unregister(); + } + + expansions.clear(); + } + + private String normalizeId(String rawId) { + final String id = rawId.trim().toLowerCase(Locale.ROOT); + if (id.isEmpty()) { + throw new IllegalArgumentException("Placeholder identifier cannot be empty"); + } + + return id; + } + + private static final class PlaceholderExpansionAdapter extends PlaceholderExpansion { + + private final Plugin plugin; + private final PluginPlaceholder delegate; + + private PlaceholderExpansionAdapter(@NotNull Plugin plugin, @NotNull PluginPlaceholder delegate) { + this.plugin = plugin; + this.delegate = delegate; + } + + @Override + @NotNull + public String getIdentifier() { + return delegate.identifier(); + } + + @Override + @NotNull + public String getAuthor() { + return String.join(", ", plugin.getDescription().getAuthors()); + } + + @Override + @NotNull + public String getVersion() { + return plugin.getDescription().getVersion(); + } + + @Override + public String onPlaceholderRequest(Player player, @NotNull String params) { + if (player == null) { + return null; + } + + return delegate.request(player, params); + } + + @Override + public boolean persist() { + return true; + } + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PlaceholderRegistry.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PlaceholderRegistry.java new file mode 100644 index 0000000..c53a314 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PlaceholderRegistry.java @@ -0,0 +1,13 @@ +package com.github.imdmk.playtime.platform.placeholder; + +import org.jetbrains.annotations.NotNull; + +public interface PlaceholderRegistry { + + void register(@NotNull PluginPlaceholder placeholder); + + void unregister(@NotNull PluginPlaceholder placeholder); + + void unregisterAll(); + +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PlaceholderRegistryFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PlaceholderRegistryFactory.java new file mode 100644 index 0000000..99f252a --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PlaceholderRegistryFactory.java @@ -0,0 +1,30 @@ +package com.github.imdmk.playtime.platform.placeholder; + +import com.github.imdmk.playtime.platform.logger.PluginLogger; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; + +final class PlaceholderRegistryFactory { + + private static final String PAPI_PLUGIN_NAME = "PlaceholderAPI"; + + PlaceholderRegistryFactory() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); + } + + static PlaceholderRegistry create( + @NotNull Plugin plugin, + @NotNull PluginLogger logger + ) { + final Plugin papi = plugin.getServer() + .getPluginManager() + .getPlugin(PAPI_PLUGIN_NAME); + + if (papi == null) { + return new DisabledPlaceholderRegistry(); + } + + logger.info("Founded PlaceholderAPI plugin! Enabling integration..."); + return new PapiPlaceholderRegistry(plugin); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PlaceholderService.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PlaceholderService.java new file mode 100644 index 0000000..3a87d15 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PlaceholderService.java @@ -0,0 +1,36 @@ +package com.github.imdmk.playtime.platform.placeholder; + +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.injector.subscriber.Subscribe; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeShutdownEvent; +import com.github.imdmk.playtime.platform.logger.PluginLogger; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +@Service +public final class PlaceholderService { + + private final PlaceholderRegistry registry; + + @Inject + public PlaceholderService( + @NotNull Plugin plugin, + @NotNull PluginLogger logger + ) { + this.registry = PlaceholderRegistryFactory.create(plugin, logger); + } + + public void register(@NotNull PluginPlaceholder placeholder) { + registry.register(placeholder); + } + + public void unregister(@NotNull PluginPlaceholder placeholder) { + registry.unregister(placeholder); + } + + @Subscribe(event = PlayTimeShutdownEvent.class) + private void shutdown() { + registry.unregisterAll(); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PluginPlaceholder.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PluginPlaceholder.java index b86e551..c245c49 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PluginPlaceholder.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/PluginPlaceholder.java @@ -1,35 +1,12 @@ package com.github.imdmk.playtime.platform.placeholder; -import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -/** - * Plugin-level abstraction for a single placeholder. - *

- * This interface is framework-agnostic and does not depend on PlaceholderAPI. - * Implementations can be adapted to any placeholder platform. - */ public interface PluginPlaceholder { - /** - * Unique identifier of the placeholder set. - * Example: "playtime" - */ - @NotNull String identifier(); + String identifier(); - /** - * Called for online players, if supported by the underlying platform. - */ - default @Nullable String onRequest(@NotNull Player player, @NotNull String params) { - return null; - } + String request(@NotNull Player player, @NotNull String params); - /** - * Called for offline players, if supported by the underlying platform. - */ - default @Nullable String onRequest(@NotNull OfflinePlayer player, @NotNull String params) { - return null; - } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/NoopPlaceholderAdapter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/NoopPlaceholderAdapter.java deleted file mode 100644 index 3b903a1..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/NoopPlaceholderAdapter.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.imdmk.playtime.platform.placeholder.adapter; - -import com.github.imdmk.playtime.platform.placeholder.PluginPlaceholder; -import org.jetbrains.annotations.NotNull; - -final class NoopPlaceholderAdapter implements PlaceholderAdapter { - - @Override - public boolean isAvailable() { - return false; - } - - @Override - public void register(@NotNull PluginPlaceholder placeholder) {} - - @Override - public void unregister(@NotNull PluginPlaceholder placeholder) {} - - @Override - public void unregisterAll() {} -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/PlaceholderAPIAdapter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/PlaceholderAPIAdapter.java deleted file mode 100644 index 5be739c..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/PlaceholderAPIAdapter.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.github.imdmk.playtime.platform.placeholder.adapter; - -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.platform.placeholder.PluginPlaceholder; -import com.github.imdmk.playtime.shared.validate.Validator; -import me.clip.placeholderapi.expansion.PlaceholderExpansion; -import org.bukkit.OfflinePlayer; -import org.bukkit.entity.Player; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.HashMap; -import java.util.Map; - -final class PlaceholderAPIAdapter implements PlaceholderAdapter { - - private final Plugin plugin; - private final PluginLogger logger; - - private final Map expansions = new HashMap<>(); - - PlaceholderAPIAdapter(@NotNull Plugin plugin, @NotNull PluginLogger logger) { - this.plugin = Validator.notNull(plugin, "plugin cannot be null"); - this.logger = Validator.notNull(logger, "logger cannot be null"); - } - - @Override - public boolean isAvailable() { - return true; - } - - @Override - public void register(@NotNull PluginPlaceholder placeholder) { - Validator.notNull(placeholder, "placeholder cannot be null"); - - if (expansions.containsKey(placeholder)) { - logger.warn("Placeholder with name %s is already registered!", placeholder.identifier()); - return; - } - - final PlaceholderExpansion expansion = new DelegatingExpansion(plugin, placeholder); - if (expansion.register()) { - expansions.put(placeholder, expansion); - } - } - - @Override - public void unregister(@NotNull PluginPlaceholder placeholder) { - Validator.notNull(placeholder, "placeholder cannot be null"); - - final PlaceholderExpansion expansion = expansions.remove(placeholder); - if (expansion != null) { - expansion.unregister(); - } - } - - @Override - public void unregisterAll() { - for (final PlaceholderExpansion expansion : expansions.values()) { - expansion.unregister(); - } - - expansions.clear(); - } - - private static final class DelegatingExpansion extends PlaceholderExpansion { - - private final Plugin plugin; - private final PluginPlaceholder delegate; - - private DelegatingExpansion(@NotNull Plugin plugin, @NotNull PluginPlaceholder delegate) { - this.plugin = plugin; - this.delegate = delegate; - } - - @Override - public @NotNull String getIdentifier() { - return delegate.identifier(); - } - - @Override - public @NotNull String getAuthor() { - return String.join(", ", plugin.getDescription().getAuthors()); - } - - @Override - public @NotNull String getVersion() { - return plugin.getDescription().getVersion(); - } - - @Override - public @Nullable String onPlaceholderRequest(Player player, @NotNull String params) { - if (player == null) { - return null; - } - - return delegate.onRequest(player, params); - } - - @Override - public @Nullable String onRequest(OfflinePlayer player, @NotNull String params) { - if (player == null) { - return null; - } - - return delegate.onRequest(player, params); - } - } -} - diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/PlaceholderAdapter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/PlaceholderAdapter.java deleted file mode 100644 index e1fb145..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/PlaceholderAdapter.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.imdmk.playtime.platform.placeholder.adapter; - -import com.github.imdmk.playtime.platform.placeholder.PluginPlaceholder; -import org.jetbrains.annotations.NotNull; - -/** - * Strategy for registering {@link PluginPlaceholder} instances - * against a concrete placeholder platform (e.g. PlaceholderAPI), - * or acting as a no-op implementation when the platform is not present. - */ -public interface PlaceholderAdapter { - - /** - * @return {@code true} if the underlying placeholder platform is available. - */ - boolean isAvailable(); - - /** - * Registers the given placeholder if the platform is available. - */ - void register(@NotNull PluginPlaceholder placeholder); - - /** - * Unregisters the given placeholder, if it was registered. - */ - void unregister(@NotNull PluginPlaceholder placeholder); - - /** - * Unregisters all placeholders managed by this registrar. - */ - void unregisterAll(); -} - diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/PlaceholderAdapterFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/PlaceholderAdapterFactory.java deleted file mode 100644 index baa0e53..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/placeholder/adapter/PlaceholderAdapterFactory.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.github.imdmk.playtime.platform.placeholder.adapter; - -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.bukkit.Server; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.NotNull; - -/** - * Factory responsible for creating the appropriate {@link PlaceholderAdapter} - * implementation based on runtime plugin availability. - * - *

This class detects whether PlaceholderAPI is installed and enabled on the server. - * Depending on its presence, it returns either:

- * - *
    - *
  • {@link PlaceholderAPIAdapter} – full integration with PlaceholderAPI;
  • - *
  • {@link NoopPlaceholderAdapter} – a no-operation fallback that safely disables - * placeholder support without causing errors.
  • - *
- * - *

This allows the plugin to offer optional PlaceholderAPI integration without requiring it - * as a hard dependency, while keeping all placeholder logic abstracted behind - * the {@link PlaceholderAdapter} interface.

- * - *

Thread-safety: The factory contains no mutable state and is fully thread-safe.

- */ -public final class PlaceholderAdapterFactory { - - private static final String PLACEHOLDER_API_NAME = "PlaceholderAPI"; - - /** - * Creates a {@link PlaceholderAdapter} appropriate for the current server environment. - * - *

If PlaceholderAPI is detected and enabled, a {@link PlaceholderAPIAdapter} is returned. - * Otherwise, a {@link NoopPlaceholderAdapter} is provided, which safely performs no operations.

- * - * @param plugin the owning plugin instance; must not be null - * @param server the Bukkit server instance; must not be null - * @param logger the plugin logger for diagnostic output; must not be null - * @return a fully initialized placeholder adapter suitable for the environment - * @throws NullPointerException if any argument is null - */ - public static PlaceholderAdapter createFor( - @NotNull Plugin plugin, - @NotNull Server server, - @NotNull PluginLogger logger - ) { - Validator.notNull(plugin, "plugin cannot be null"); - Validator.notNull(server, "server cannot be null"); - Validator.notNull(logger, "logger cannot be null"); - - boolean isEnabled = server.getPluginManager().isPluginEnabled(PLACEHOLDER_API_NAME); - if (isEnabled) { - logger.info("PlaceholderAPI detected — using PlaceholderApiAdapter."); - return new PlaceholderAPIAdapter(plugin, logger); - } - - logger.info("PlaceholderAPI not found — using NoopPlaceholderAdapter."); - return new NoopPlaceholderAdapter(); - } - - private PlaceholderAdapterFactory() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); - } -} - diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/playtime/BukkitPlayTimeAdapter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/playtime/BukkitPlayTimeAdapter.java new file mode 100644 index 0000000..f8918a1 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/playtime/BukkitPlayTimeAdapter.java @@ -0,0 +1,38 @@ +package com.github.imdmk.playtime.platform.playtime; + +import com.github.imdmk.playtime.PlayTime; +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; +import org.bukkit.Statistic; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +@Service(priority = ComponentPriority.LOW) +final class BukkitPlayTimeAdapter implements PlayTimeAdapter { + + private final TaskScheduler scheduler; + + @Inject + BukkitPlayTimeAdapter(@NotNull TaskScheduler scheduler) { + this.scheduler = scheduler; + } + + @NotNull + @Override + public PlayTime read(@NotNull Player player) { + return PlayTime.ofTicks(player.getStatistic(Statistic.PLAY_ONE_MINUTE)); + } + + @Override + public void write(@NotNull Player player, @NotNull PlayTime playTime) { + scheduler.runSync(() -> { + if (!player.isOnline()) { + return; + } + + player.setStatistic(Statistic.PLAY_ONE_MINUTE, playTime.toTicks()); + }); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/playtime/PlayTimeAdapter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/playtime/PlayTimeAdapter.java new file mode 100644 index 0000000..f9de911 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/playtime/PlayTimeAdapter.java @@ -0,0 +1,14 @@ +package com.github.imdmk.playtime.platform.playtime; + +import com.github.imdmk.playtime.PlayTime; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +public interface PlayTimeAdapter { + + @NotNull PlayTime read(@NotNull Player player); + + void write(@NotNull Player player, @NotNull PlayTime playTime); + +} + diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/scheduler/BukkitTaskScheduler.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/scheduler/BukkitTaskScheduler.java index 2c07b97..c424072 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/scheduler/BukkitTaskScheduler.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/scheduler/BukkitTaskScheduler.java @@ -1,124 +1,72 @@ package com.github.imdmk.playtime.platform.scheduler; -import com.github.imdmk.playtime.shared.validate.Validator; +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.injector.subscriber.Subscribe; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeShutdownEvent; +import com.github.imdmk.playtime.shared.time.Durations; import org.bukkit.plugin.Plugin; import org.bukkit.scheduler.BukkitScheduler; import org.bukkit.scheduler.BukkitTask; import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; import java.time.Duration; -/** - * {@link TaskScheduler} implementation backed by the Bukkit {@link BukkitScheduler}. - * - *

Provides a clean, Duration-based API for scheduling synchronous and asynchronous - * tasks, including delayed and repeating executions.

- * - *

All time values are expressed using {@link Duration} and internally converted - * to Minecraft ticks (1 tick = 50 ms).

- * - *

Thread-safety: This class is thread-safe. It holds only immutable - * references to {@link Plugin} and {@link BukkitScheduler}.

- */ -public final class BukkitTaskScheduler implements TaskScheduler { - - /** Number of milliseconds per Minecraft tick. */ - private static final long MILLIS_PER_TICK = 50L; +@Service(priority = ComponentPriority.LOWEST) +final class BukkitTaskScheduler implements TaskScheduler { private final Plugin plugin; private final BukkitScheduler scheduler; - public BukkitTaskScheduler(@NotNull Plugin plugin, @NotNull BukkitScheduler scheduler) { - this.plugin = Validator.notNull(plugin, "plugin cannot be null"); - this.scheduler = Validator.notNull(scheduler, "scheduler cannot be null"); + @Inject + BukkitTaskScheduler(@NotNull Plugin plugin, @NotNull BukkitScheduler scheduler) { + this.plugin = plugin; + this.scheduler = scheduler; } @Override - public @NotNull BukkitTask runSync(@NotNull Runnable runnable) { - Validator.notNull(runnable, "runnable cannot be null"); + public BukkitTask runSync(@NotNull Runnable runnable) { return scheduler.runTask(plugin, runnable); } @Override - public @NotNull BukkitTask runSync(@NotNull PluginTask task) { - Validator.notNull(task, "task cannot be null"); - return scheduler.runTask(plugin, task); - } - - @Override - public @NotNull BukkitTask runAsync(@NotNull Runnable runnable) { - Validator.notNull(runnable, "runnable cannot be null"); + public BukkitTask runAsync(@NotNull Runnable runnable) { return scheduler.runTaskAsynchronously(plugin, runnable); } @Override - public @NotNull BukkitTask runAsync(@NotNull PluginTask task) { - Validator.notNull(task, "task cannot be null"); - return scheduler.runTaskAsynchronously(plugin, task); - } - - @Override - public @NotNull BukkitTask runLaterAsync(@NotNull Runnable runnable, @NotNull Duration delay) { - Validator.notNull(runnable, "runnable cannot be null"); - Validator.notNull(delay, "delay cannot be null"); - return scheduler.runTaskLaterAsynchronously(plugin, runnable, toTicks(delay)); - } - - @Override - public @NotNull BukkitTask runLaterAsync(@NotNull PluginTask task) { - Validator.notNull(task, "task cannot be null"); - return runLaterAsync(task, task.delay()); - } - - @Override - public @NotNull BukkitTask runLaterSync(@NotNull Runnable runnable, @NotNull Duration delay) { - Validator.notNull(runnable, "runnable cannot be null"); - Validator.notNull(delay, "delay cannot be null"); - return scheduler.runTaskLater(plugin, runnable, toTicks(delay)); + public BukkitTask runLaterAsync( + @NotNull Runnable runnable, + @NotNull Duration delay + ) { + return scheduler.runTaskLaterAsynchronously(plugin, runnable, Durations.convertToTicks(delay)); } @Override - public @NotNull BukkitTask runLaterSync(@NotNull PluginTask task) { - Validator.notNull(task, "task cannot be null"); - return runLaterSync(task, task.delay()); + public BukkitTask runLaterSync( + @NotNull Runnable runnable, + @NotNull Duration delay + ) { + return scheduler.runTaskLater(plugin, runnable, Durations.convertToTicks(delay)); } @Override - public @NotNull BukkitTask runTimerSync( + public BukkitTask runTimerSync( @NotNull Runnable runnable, @NotNull Duration delay, @NotNull Duration period ) { - Validator.notNull(runnable, "runnable cannot be null"); - Validator.notNull(delay, "delay cannot be null"); - Validator.notNull(period, "period cannot be null"); - - return scheduler.runTaskTimer(plugin, runnable, toTicks(delay), toTicks(period)); + return scheduler.runTaskTimer(plugin, runnable, Durations.convertToTicks(delay), Durations.convertToTicks(period)); } @Override - public @NotNull BukkitTask runTimerSync(@NotNull PluginTask task) { - Validator.notNull(task, "task cannot be null"); - return runTimerSync(task, task.delay(), task.period()); - } - - @Override - public @NotNull BukkitTask runTimerAsync( + public BukkitTask runTimerAsync( @NotNull Runnable runnable, @NotNull Duration delay, @NotNull Duration period ) { - Validator.notNull(runnable, "runnable cannot be null"); - Validator.notNull(delay, "delay cannot be null"); - Validator.notNull(period, "period cannot be null"); - - return scheduler.runTaskTimerAsynchronously(plugin, runnable, toTicks(delay), toTicks(period)); - } - - @Override - public @NotNull BukkitTask runTimerAsync(@NotNull PluginTask task) { - Validator.notNull(task, "task cannot be null"); - return runTimerAsync(task, task.delay(), task.period()); + return scheduler.runTaskTimerAsynchronously(plugin, runnable, Durations.convertToTicks(delay), Durations.convertToTicks(period)); } @Override @@ -127,22 +75,12 @@ public void cancelTask(int taskId) { } @Override - public void shutdown() { + public void cancelAllTasks() { scheduler.cancelTasks(plugin); } - /** - * Converts the given duration to Minecraft ticks. - *

- * Fractions are truncated. Negative durations return {@code 0}. - * - * @param duration duration to convert; must not be null - * @return number of ticks (≥ 0) - */ - private static int toTicks(@NotNull Duration duration) { - Validator.notNull(duration, "duration cannot be null"); - - long ticks = duration.toMillis() / MILLIS_PER_TICK; - return ticks <= 0 ? 0 : (int) ticks; + @Subscribe(event = PlayTimeShutdownEvent.class) + private void shutdown() { + cancelAllTasks(); } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/scheduler/PluginTask.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/scheduler/PluginTask.java deleted file mode 100644 index 21ea995..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/scheduler/PluginTask.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.github.imdmk.playtime.platform.scheduler; - -import java.time.Duration; - -/** - * Represents a declarative task definition used by the {@link TaskScheduler}. - * - *

A {@code PluginTask} bundles together:

- *
    - *
  • the executable logic ({@link #run()}),
  • - *
  • a delay before the first execution ({@link #delay()}),
  • - *
  • an optional repeating period ({@link #period()}).
  • - *
- * - *

Instances are consumed by scheduler methods that accept {@link PluginTask}, - * allowing tasks to be declared as self-contained objects instead of passing - * raw parameters into every scheduling call.

- * - *

Repeating vs. non-repeating:

- *
    - *
  • If {@link #period()} returns {@code Duration.ZERO}, the task is executed once after the delay.
  • - *
  • If {@link #period()} is greater than zero, the task is executed repeatedly.
  • - *
- * - *

Threading: Whether the task runs synchronously or asynchronously - * depends solely on the {@link TaskScheduler} method used (e.g., {@code runTimerSync}, {@code runTimerAsync}).

- */ -public interface PluginTask extends Runnable { - - /** - * The task logic to be executed by the scheduler. - *

- * Called either once (if {@link #period()} is zero) or repeatedly - * (if {@link #period()} is greater than zero), depending on how - * the task is scheduled. - *

- */ - @Override - void run(); - - /** - * Returns the delay before the first execution. - * - *

A zero delay means the task should run immediately.

- * - * @return the initial delay, never {@code null} - */ - Duration delay(); - - /** - * Returns the repeat period for this task. - * - *

If this returns {@code Duration.ZERO}, the task is treated as - * a one-shot task and will not repeat after the first execution.

- * - *

If the value is greater than zero, the scheduler executes the - * task repeatedly with this interval.

- * - * @return the repeat interval, never {@code null} - */ - Duration period(); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/scheduler/TaskScheduler.java b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/scheduler/TaskScheduler.java index dde36b9..5611189 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/platform/scheduler/TaskScheduler.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/platform/scheduler/TaskScheduler.java @@ -5,148 +5,17 @@ import java.time.Duration; -/** - * Abstraction layer over the Bukkit {@link org.bukkit.scheduler.BukkitScheduler}, - * providing a clean, consistent API for scheduling synchronous and asynchronous tasks - * using either raw {@link Runnable} instances or declarative {@link PluginTask} objects. - * - *

Threading rules:

- *
    - *
  • Sync methods execute on the main server thread.
  • - *
  • Async methods execute off the main thread and must not access Bukkit API objects that require sync.
  • - *
- * - *

Delay & period units: All {@code Duration} values are converted to - * Minecraft ticks (1 tick = 50ms).

- * - *

PluginTask usage: All overloads accepting {@link PluginTask} - * automatically use the task's declared delay and period.

- */ public interface TaskScheduler { - /** - * Executes the given runnable immediately on the main server thread. - * - * @param runnable non-null logic to execute - * @return the task handle - */ BukkitTask runSync(@NotNull Runnable runnable); - - /** - * Executes the given {@link PluginTask} immediately on the main server thread. - * - *

{@link PluginTask#delay()} and {@link PluginTask#period()} are ignored; - * this method always runs instantly.

- * - * @param task non-null task instance - * @return the task handle - */ - BukkitTask runSync(@NotNull PluginTask task); - - /** - * Executes the given runnable immediately on a separate thread. - * - * @param runnable non-null logic to execute asynchronously - * @return the task handle - */ BukkitTask runAsync(@NotNull Runnable runnable); - /** - * Executes the given {@link PluginTask} immediately on a separate thread. - * - *

{@link PluginTask#delay()} and {@link PluginTask#period()} are ignored; - * this method always runs instantly.

- * - * @param task non-null task instance - * @return the task handle - */ - BukkitTask runAsync(@NotNull PluginTask task); - - /** - * Executes the runnable asynchronously after the given delay. - * - * @param runnable task logic - * @param delay delay before execution (converted to ticks) - * @return the task handle - */ BukkitTask runLaterAsync(@NotNull Runnable runnable, @NotNull Duration delay); - - /** - * Executes the {@link PluginTask} asynchronously after {@link PluginTask#delay()}. - * - *

Runs once unless {@link PluginTask#period()} is non-zero.

- * - * @param task task definition - * @return the task handle - */ - BukkitTask runLaterAsync(@NotNull PluginTask task); - - /** - * Executes the runnable synchronously after the given delay. - * - * @param runnable task logic - * @param delay delay before execution (converted to ticks) - * @return the task handle - */ BukkitTask runLaterSync(@NotNull Runnable runnable, @NotNull Duration delay); - /** - * Executes the {@link PluginTask} synchronously after {@link PluginTask#delay()}. - * - *

Runs once unless {@link PluginTask#period()} is non-zero.

- * - * @param task task definition - * @return the task handle - */ - BukkitTask runLaterSync(@NotNull PluginTask task); - - /** - * Schedules a synchronous repeating task. - * - * @param runnable logic to execute - * @param delay initial delay before the first run - * @param period time between runs - * @return the created repeating task - */ BukkitTask runTimerSync(@NotNull Runnable runnable, @NotNull Duration delay, @NotNull Duration period); - - /** - * Schedules a synchronous repeating {@link PluginTask} using its delay/period. - * - * @param task task definition - * @return the created repeating task - */ - BukkitTask runTimerSync(@NotNull PluginTask task); - - /** - * Schedules an asynchronous repeating task. - * - * @param runnable logic to execute - * @param delay initial delay before the first execution - * @param period time between consecutive executions - * @return the created repeating task - */ BukkitTask runTimerAsync(@NotNull Runnable runnable, @NotNull Duration delay, @NotNull Duration period); - /** - * Schedules an asynchronous repeating {@link PluginTask} using its delay/period. - * - * @param task task definition - * @return the created repeating task - */ - BukkitTask runTimerAsync(@NotNull PluginTask task); - - /** - * Cancels a scheduled task via its Bukkit ID. - * - * @param taskId scheduler task ID - */ void cancelTask(int taskId); - - /** - * Cancels all tasks created for the associated plugin. - * - *

Called during plugin shutdown.

- */ - void shutdown(); + void cancelAllTasks(); } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/message/MessageConfig.java b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/message/MessageConfig.java similarity index 93% rename from playtime-core/src/main/java/com/github/imdmk/playtime/message/MessageConfig.java rename to playtime-core/src/main/java/com/github/imdmk/playtime/shared/message/MessageConfig.java index 0167806..e655569 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/message/MessageConfig.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/message/MessageConfig.java @@ -1,4 +1,4 @@ -package com.github.imdmk.playtime.message; +package com.github.imdmk.playtime.shared.message; import com.eternalcode.multification.notice.Notice; import com.eternalcode.multification.notice.resolver.NoticeResolverDefaults; @@ -6,10 +6,12 @@ import com.github.imdmk.playtime.config.ConfigSection; import com.github.imdmk.playtime.feature.playtime.messages.ENPlayTimeMessages; import com.github.imdmk.playtime.feature.reload.messages.ENReloadMessages; +import com.github.imdmk.playtime.injector.annotations.ConfigFile; import eu.okaeri.configs.annotation.Comment; import eu.okaeri.configs.serdes.OkaeriSerdesPack; import org.jetbrains.annotations.NotNull; +@ConfigFile public final class MessageConfig extends ConfigSection { @Comment({ @@ -82,21 +84,21 @@ public final class MessageConfig extends ConfigSection { + "If the problem persists, contact an administrator." ); - @Comment({" ", "# Playtime messages", " "}) + @Comment({" ", "# PlayTime messages", " "}) public ENPlayTimeMessages playtimeMessages = new ENPlayTimeMessages(); @Comment({" ", "# Reload messages", " "}) public ENReloadMessages reloadMessages = new ENReloadMessages(); @Override - public @NotNull OkaeriSerdesPack getSerdesPack() { + public @NotNull OkaeriSerdesPack serdesPack() { return registry -> registry.register( new MultificationSerdesPack(NoticeResolverDefaults.createRegistry()) ); } @Override - public @NotNull String getFileName() { + public @NotNull String fileName() { return "messageConfig.yml"; } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/shared/message/MessageService.java b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/message/MessageService.java new file mode 100644 index 0000000..55f61d2 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/message/MessageService.java @@ -0,0 +1,65 @@ +package com.github.imdmk.playtime.shared.message; + +import com.eternalcode.multification.adventure.AudienceConverter; +import com.eternalcode.multification.bukkit.BukkitMultification; +import com.eternalcode.multification.notice.provider.NoticeProvider; +import com.eternalcode.multification.translation.TranslationProvider; +import com.github.imdmk.playtime.injector.ComponentPriority; +import com.github.imdmk.playtime.injector.annotations.Service; +import com.github.imdmk.playtime.injector.subscriber.Subscribe; +import com.github.imdmk.playtime.injector.subscriber.event.PlayTimeShutdownEvent; +import com.github.imdmk.playtime.platform.adventure.AdventureComponents; +import net.kyori.adventure.platform.AudienceProvider; +import net.kyori.adventure.platform.bukkit.BukkitAudiences; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.ComponentSerializer; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.panda_lang.utilities.inject.annotations.Inject; + +@Service(priority = ComponentPriority.LOW) +public final class MessageService extends BukkitMultification { + + private final MessageConfig messageConfig; + private final AudienceProvider audienceProvider; + + @Inject + public MessageService(@NotNull Plugin plugin, @NotNull MessageConfig messageConfig) { + this.messageConfig = messageConfig; + this.audienceProvider = BukkitAudiences.create(plugin); + } + + @NotNull + @Override + protected TranslationProvider translationProvider() { + return provider -> messageConfig; + } + + @NotNull + @Override + protected ComponentSerializer serializer() { + return AdventureComponents.miniMessage(); + } + + @NotNull + @Override + protected AudienceConverter audienceConverter() { + return sender -> { + if (sender instanceof Player player) { + return audienceProvider.player(player.getUniqueId()); + } + return audienceProvider.console(); + }; + } + + public void send(CommandSender sender, NoticeProvider notice) { + create().viewer(sender).notice(notice).send(); + } + + @Subscribe(event = PlayTimeShutdownEvent.class) + public void shutdown() { + audienceProvider.close(); + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationFormatStyle.java b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationFormatStyle.java index dd3f53c..f444656 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationFormatStyle.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationFormatStyle.java @@ -7,109 +7,56 @@ import java.util.function.BiFunction; import java.util.stream.Collectors; -/** - * Defines formatting strategies for converting a {@link Duration} into - * a human-readable string. - *

- * Each style provides its own implementation of {@link #format(Duration)}. - * The underlying logic splits the duration into days, hours, minutes and seconds - * and then renders only non-zero units in a style-specific way. - */ public enum DurationFormatStyle { - /** - * Compact representation using short unit abbreviations. - *

- * Example: {@code 30d 30m 3s} - */ COMPACT { @Override - public String format(@NotNull Duration duration) { + String format(@NotNull Duration duration) { return formatWith(duration, (unit, value) -> value + unit.getAbbreviation(), Separator.SPACE); } }, - - /** - * Long form with full unit names, separated by spaces. - *

- * Example: {@code 30 days 30 minutes 3 seconds} - */ LONG { @Override - public String format(@NotNull Duration duration) { + String format(@NotNull Duration duration) { return formatWith(duration, DurationUnit::toDisplayName, Separator.SPACE); } }, - - /** - * Long form with {@code " and "} between units. - *

- * Example: {@code 30 days and 30 minutes and 3 seconds} - */ LONG_WITH_AND { @Override - public String format(@NotNull Duration duration) { + String format(@NotNull Duration duration) { return formatWith(duration, DurationUnit::toDisplayName, Separator.AND); } }, - - /** - * Natural language-like form using commas between units. - *

- * Example: {@code 30 days, 30 minutes, 3 seconds} - */ NATURAL { @Override - public String format(@NotNull Duration duration) { + String format(@NotNull Duration duration) { return formatWith(duration, DurationUnit::toDisplayName, Separator.COMMA); } }; - /** - * Formats the given {@link Duration} using this style. - *

- * The duration is first decomposed into days, hours, minutes and seconds, - * and only non-zero units are included in the output. - * - * @param duration the duration to format; must not be {@code null} - * @return formatted duration according to this style (never {@code null}) - */ - public abstract String format(@NotNull Duration duration); + abstract String format(@NotNull Duration duration); - /** - * Joins non-zero units of the given duration using the provided formatter - * and separator. - * - * @param duration duration to format - * @param valueFormatter function converting (unit, value) → string - * @param separator separator strategy - * @return formatted string, or empty string if all units are zero - */ - protected static String formatWith( + static String formatWith( @NotNull Duration duration, @NotNull BiFunction valueFormatter, @NotNull Separator separator ) { final Map parts = DurationSplitter.split(duration); - return parts.entrySet().stream() .filter(e -> e.getValue() > 0) .map(e -> valueFormatter.apply(e.getKey(), e.getValue())) .collect(Collectors.joining(separator.value())); } - /** - * Separator strategies used between formatted units. - */ - protected enum Separator { + enum Separator { SPACE(" "), AND(" and "), @@ -121,13 +68,7 @@ protected enum Separator { this.value = value; } - /** - * Returns the underlying separator string. - * - * @return separator value - */ - @NotNull - public String value() { + String value() { return value; } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationSplitter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationSplitter.java index 58f9476..6a5db0a 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationSplitter.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationSplitter.java @@ -1,36 +1,20 @@ package com.github.imdmk.playtime.shared.time; -import com.github.imdmk.playtime.shared.validate.Validator; import org.jetbrains.annotations.NotNull; import java.time.Duration; import java.util.EnumMap; import java.util.Map; -/** - * Utility class responsible for splitting a {@link Duration} - * into its component units (days, hours, minutes, seconds). - *

- * This keeps the extraction logic in a single place, shared across - * different formatters. - */ -public final class DurationSplitter { +final class DurationSplitter { - private DurationSplitter() { + DurationSplitter() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - /** - * Splits the given duration into ordered units: days, hours, minutes, seconds. - * - * @param duration the duration to split (non-null) - * @return map of {@link DurationUnit} to its value in the given duration - */ - public static @NotNull Map split(@NotNull Duration duration) { - Validator.notNull(duration, "duration"); - - EnumMap parts = new EnumMap<>(DurationUnit.class); - for (DurationUnit unit : DurationUnit.ORDERED) { + static Map split(@NotNull Duration duration) { + final EnumMap parts = new EnumMap<>(DurationUnit.class); + for (final DurationUnit unit : DurationUnit.ORDERED) { parts.put(unit, unit.extract(duration)); } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationUnit.java b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationUnit.java index 590694e..561b0ff 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationUnit.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/DurationUnit.java @@ -4,12 +4,7 @@ import java.time.Duration; -/** - * Supported duration units and their metadata. - *

- * This enum centralizes singular/plural names, abbreviations, and extraction logic. - */ -public enum DurationUnit { +enum DurationUnit { DAY("day", "days", "d") { @Override @@ -36,8 +31,7 @@ public int extract(@NotNull Duration duration) { } }; - /** Ordered for consistent output. */ - public static final DurationUnit[] ORDERED = { + static final DurationUnit[] ORDERED = { DAY, HOUR, MINUTE, SECOND }; @@ -53,14 +47,14 @@ public int extract(@NotNull Duration duration) { this.abbreviation = abbreviation; } - public abstract int extract(@NotNull Duration duration); + abstract int extract(@NotNull Duration duration); - public @NotNull String getAbbreviation() { + String getAbbreviation() { return abbreviation; } - public @NotNull String toDisplayName(int value) { - String word = (value == 1 ? singular : plural); + String toDisplayName(int value) { + final String word = (value == 1 ? singular : plural); return DISPLAY_NAME_FORMAT.formatted(value, word); } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/Durations.java b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/Durations.java index f8ef205..d2eb1bb 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/Durations.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/time/Durations.java @@ -1,59 +1,22 @@ package com.github.imdmk.playtime.shared.time; -import com.github.imdmk.playtime.shared.validate.Validator; import org.jetbrains.annotations.NotNull; import java.time.Duration; -/** - * Utility class providing human-readable formatting helpers for {@link Duration}. - *

- * Supports multiple predefined {@link DurationFormatStyle} strategies. - * Zero or negative durations are normalized to {@code "<1s"}. - *

- * This class is stateless apart from the configurable default style. - */ public final class Durations { - /** Upper bound for any clamped duration (10 years). */ private static final Duration MAX_NORMALIZED_DURATION = Duration.ofDays(3650); - - /** Returned when the duration is zero or negative. */ private static final String LESS_THAN_SECOND = "<1s"; + private static final long MILLIS_PER_TICK = 50L; - /** Default style used when no explicit format style is provided. */ - private static DurationFormatStyle DEFAULT_FORMAT_STYLE = DurationFormatStyle.NATURAL; + private static DurationFormatStyle FORMAT_STYLE = DurationFormatStyle.NATURAL; private Durations() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } - /** - * Formats the given duration using {@link #DEFAULT_FORMAT_STYLE}. - *

- * Zero or negative durations return {@code "<1s"}. - * - * @param duration the duration to format (non-null) - * @return formatted duration string (never {@code null}) - */ - public static @NotNull String format(@NotNull Duration duration) { - return format(duration, DEFAULT_FORMAT_STYLE); - } - - /** - * Formats the given duration using the specified {@link DurationFormatStyle}. - *

- * Zero or negative durations return {@code "<1s"}. - * - * @param duration the duration to format (non-null) - * @param style formatting strategy (non-null) - * @return human-readable duration string (never {@code null}) - * @throws IllegalArgumentException if duration or style are {@code null} - */ - public static @NotNull String format(@NotNull Duration duration, @NotNull DurationFormatStyle style) { - Validator.notNull(duration, "duration"); - Validator.notNull(style, "style"); - + public static String format(@NotNull Duration duration, @NotNull DurationFormatStyle style) { if (duration.isZero() || duration.isNegative()) { return LESS_THAN_SECOND; } @@ -61,35 +24,24 @@ private Durations() { return style.format(duration); } - /** - * Sets the global default {@link DurationFormatStyle} used by - * {@link #format(Duration)}. - *

- * This modifies process-wide behavior and should be configured during - * plugin initialization. - * - * @param style the new default style (non-null) - * @throws IllegalArgumentException if the provided style is {@code null} - */ - public static void setDefaultFormatStyle(@NotNull DurationFormatStyle style) { - Validator.notNull(style, "durationFormatStyle"); - DEFAULT_FORMAT_STYLE = style; + public static String format(@NotNull Duration duration) { + return format(duration, FORMAT_STYLE); } - /** - * Normalizes (clamps) the given duration so it’s always non-negative - * and does not exceed {@link #MAX_NORMALIZED_DURATION}. - * - * @param input duration to normalize (must not be null) - * @return clamped, non-negative duration - */ - public static @NotNull Duration clamp(@NotNull Duration input) { - Validator.notNull(input, "duration"); - + public static Duration clamp(@NotNull Duration input) { if (input.isNegative()) { return Duration.ZERO; } return input.compareTo(MAX_NORMALIZED_DURATION) > 0 ? MAX_NORMALIZED_DURATION : input; } + + public static int convertToTicks(@NotNull Duration duration) { + long ticks = duration.toMillis() / MILLIS_PER_TICK; + return ticks <= 0 ? 0 : (int) ticks; + } + + public static void setFormatStyle(@NotNull DurationFormatStyle style) { + FORMAT_STYLE = style; + } } diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/shared/validate/Validator.java b/playtime-core/src/main/java/com/github/imdmk/playtime/shared/validate/Validator.java deleted file mode 100644 index f356272..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/shared/validate/Validator.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.github.imdmk.playtime.shared.validate; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.function.Consumer; - -/** - * Utility class for common validation checks. - *

- * Provides null-safety guards used throughout the codebase. - */ -public final class Validator { - - private Validator() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - /** - * Ensures the given object is not {@code null}. - *

- * This method is typically used to validate constructor arguments and - * configuration values. If the supplied object is non-null, it is returned - * unchanged; otherwise a {@link NullPointerException} is thrown with the - * provided message. - * - * @param obj the value to validate; may be null - * @param context context of exception used when {@code obj} is null - * @param type of the validated object - * @return the non-null value of {@code obj} - * @throws NullPointerException if {@code obj} is null - */ - public static T notNull(@Nullable T obj, @NotNull String context) { - if (obj == null) { - throw new NullPointerException(context + " cannot be null"); - } - return obj; - } - - /** - * Executes the given {@link Consumer} only if the supplied object is not {@code null}. - *

- * This helper is especially useful during shutdown or cleanup phases where - * optional components may or may not be initialized. The consumer itself - * must be non-null; however, it will only be invoked when {@code obj} is non-null. - * - *

Example usage: - *

-     * Validator.ifNotNull(taskScheduler, TaskScheduler::shutdown);
-     * Validator.ifNotNull(messageService, MessageService::shutdown);
-     * 
- * - * @param obj the object to check before executing the consumer; may be null - * @param consumer operation to execute when {@code obj} is non-null (never null) - * @param type of the object passed to the consumer - * @throws NullPointerException if {@code consumer} is null - */ - public static void ifNotNull(@Nullable T obj, @NotNull Consumer consumer) { - Validator.notNull(consumer, "consumer is null"); - if (obj != null) { - consumer.accept(obj); - } - } - -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserArgument.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserArgument.java deleted file mode 100644 index 549603d..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserArgument.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.github.imdmk.playtime.user; - -import com.github.imdmk.playtime.message.MessageConfig; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import dev.rollczi.litecommands.argument.Argument; -import dev.rollczi.litecommands.argument.parser.ParseResult; -import dev.rollczi.litecommands.argument.resolver.ArgumentResolver; -import dev.rollczi.litecommands.invocation.Invocation; -import dev.rollczi.litecommands.suggestion.SuggestionContext; -import dev.rollczi.litecommands.suggestion.SuggestionResult; -import org.bukkit.Server; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.time.Duration; -import java.util.concurrent.TimeUnit; - -/** - * Argument resolver for {@link User} objects. - *

- * Performs a cache-only lookup on the primary server thread to avoid blocking, - * and a full asynchronous lookup (cache → database) off the main thread. - */ -final class UserArgument extends ArgumentResolver { - - private static final Duration LOOKUP_TIMEOUT = Duration.ofSeconds(2); - - private final PluginLogger logger; - private final Server server; - private final MessageConfig messageConfig; - private final UserService userService; - - @Inject - UserArgument( - @NotNull PluginLogger logger, - @NotNull Server server, - @NotNull MessageConfig messageConfig, - @NotNull UserService userService - ) { - this.logger = Validator.notNull(logger, "logger"); - this.server = Validator.notNull(server, "server"); - this.messageConfig = Validator.notNull(messageConfig, "config"); - this.userService = Validator.notNull(userService, "userService"); - } - - @Override - protected ParseResult parse(Invocation invocation, - Argument context, - String argument) { - - // Main thread -> cache-only (never block the tick) - if (server.isPrimaryThread()) { - logger.warn("UserArgument lookup for '%s' on main thread – using cache only.", argument); - return userService.findCachedByName(argument) - .map(ParseResult::success) - .orElse(ParseResult.failure(messageConfig.playerNotFound)); - } - - // Off-thread -> full lookup (cache → DB) - return userService.findByName(argument) - .orTimeout(LOOKUP_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) - .join() - .map(ParseResult::success) - .orElse(ParseResult.failure(messageConfig.playerNotFound)); - } - - @Override - public SuggestionResult suggest(Invocation invocation, - Argument argument, - SuggestionContext context) { - return userService.getCachedUsers() - .stream() - .map(User::getName) - .collect(SuggestionResult.collector()); - } -} - diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserFactory.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserFactory.java deleted file mode 100644 index f2fda4a..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserFactory.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.imdmk.playtime.user; - -import org.bukkit.OfflinePlayer; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; - -/** - * Platform-to-domain adapter. Creates a new domain {@link User} from - * a Bukkit {@link Player} on first join. No update logic here. - */ -public interface UserFactory { - - /** - * Creates a fully initialized {@link User} from the given {@link Player}. - * Implementations should set initial playtime using platform stats. - */ - @NotNull User createFrom(@NotNull Player player); - - /** - * Creates a fully initialized {@link User} from the given {@link OfflinePlayer}. - * Implementations should set initial playtime using platform stats. - */ - @NotNull User createFrom(@NotNull OfflinePlayer offlinePlayer); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserModule.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserModule.java deleted file mode 100644 index 5919f4d..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserModule.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.github.imdmk.playtime.user; - -import com.github.imdmk.playtime.infrastructure.module.Module; -import com.github.imdmk.playtime.infrastructure.module.phase.CommandPhase; -import com.github.imdmk.playtime.infrastructure.module.phase.ListenerPhase; -import com.github.imdmk.playtime.infrastructure.module.phase.RepositoryPhase; -import com.github.imdmk.playtime.infrastructure.module.phase.TaskPhase; -import com.github.imdmk.playtime.user.cache.CaffeineUserCache; -import com.github.imdmk.playtime.user.cache.UserCache; -import com.github.imdmk.playtime.user.listener.UserJoinListener; -import com.github.imdmk.playtime.user.listener.UserQuitListener; -import com.github.imdmk.playtime.user.repository.UserEntityMapper; -import com.github.imdmk.playtime.user.repository.UserRepository; -import com.github.imdmk.playtime.user.repository.UserRepositoryOrmLite; -import com.github.imdmk.playtime.user.top.MemoryTopUsersCache; -import com.github.imdmk.playtime.user.top.TopUsersCache; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.Injector; -import org.panda_lang.utilities.inject.Resources; - -public final class UserModule implements Module { - - private UserCache userCache; - private UserEntityMapper userEntityMapper; - private UserRepository userRepository; - private TopUsersCache topUsersCache; - private UserService userService; - - @Override - public void bind(@NotNull Resources resources) { - resources.on(UserCache.class).assignInstance(() -> this.userCache); - resources.on(UserEntityMapper.class).assignInstance(() -> this.userEntityMapper); - resources.on(UserRepository.class).assignInstance(() -> this.userRepository); - resources.on(TopUsersCache.class).assignInstance(() -> this.topUsersCache); - resources.on(UserService.class).assignInstance(() -> this.userService); - } - - @Override - public void init(@NotNull Injector injector) { - this.userCache = new CaffeineUserCache(); - this.userEntityMapper = new UserEntityMapper(); - this.userRepository = injector.newInstance(UserRepositoryOrmLite.class); - this.topUsersCache = injector.newInstance(MemoryTopUsersCache.class); - this.userService = injector.newInstance(UserServiceImpl.class); - } - - @Override - public ListenerPhase listeners(@NotNull Injector injector) { - return registrar -> registrar.register( - injector.newInstance(UserJoinListener.class), - injector.newInstance(UserQuitListener.class) - ); - } - - @Override - public RepositoryPhase repositories(@NotNull Injector injector) { - return manager -> manager.register(this.userRepository); - } - - @Override - public CommandPhase commands(@NotNull Injector injector) { - return builder -> builder.argument(User.class, injector.newInstance(UserArgument.class)); - } - - @Override - public TaskPhase tasks(@NotNull Injector injector) { - return scheduler -> { - scheduler.runTimerAsync(injector.newInstance(UserSaveTask.class)); - }; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserSaveTask.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserSaveTask.java deleted file mode 100644 index b6a2ce2..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserSaveTask.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.github.imdmk.playtime.user; - -import com.github.imdmk.playtime.PlaytimeService; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.platform.scheduler.PluginTask; -import com.github.imdmk.playtime.shared.validate.Validator; -import org.bukkit.Server; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.time.Duration; - -final class UserSaveTask implements PluginTask { - - private static final Duration INITIAL_DELAY = Duration.ofMinutes(30); - private static final Duration INTERVAL = Duration.ofMinutes(30); - - private final Server server; - private final PluginLogger logger; - private final PlaytimeService playtimeService; - private final UserService userService; - - @Inject - UserSaveTask( - @NotNull Server server, - @NotNull PluginLogger logger, - @NotNull PlaytimeService playtimeService, - @NotNull UserService userService - ) { - this.server = Validator.notNull(server, "server"); - this.logger = Validator.notNull(logger, "logger"); - this.playtimeService = Validator.notNull(playtimeService, "playtime"); - this.userService = Validator.notNull(userService, "userService"); - } - - @Override - public void run() { - for (final Player player : server.getOnlinePlayers()) { - userService.findCachedByUuid(player.getUniqueId()) - .ifPresent(this::updateAndSaveUser); - } - } - - private void updateAndSaveUser(@NotNull User user) { - UserTime time = playtimeService.getTime(user.getUuid()); - user.setPlaytime(time); - - userService.save(user, UserSaveReason.SCHEDULED_SAVE) - .exceptionally(e -> { - logger.error(e, "Failed to perform scheduled save for user %s (%s)", user.getName(), user.getUuid()); - return null; - }); - } - - @Override - public Duration delay() { - return INITIAL_DELAY; - } - - @Override - public Duration period() { - return INTERVAL; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserServiceImpl.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserServiceImpl.java deleted file mode 100644 index 93286a4..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/UserServiceImpl.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.github.imdmk.playtime.user; - -import com.github.imdmk.playtime.UserDeleteEvent; -import com.github.imdmk.playtime.UserPreSaveEvent; -import com.github.imdmk.playtime.UserSaveEvent; -import com.github.imdmk.playtime.platform.events.BukkitEventCaller; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.cache.UserCache; -import com.github.imdmk.playtime.user.repository.UserRepository; -import com.github.imdmk.playtime.user.top.TopUsersCache; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Unmodifiable; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.time.Duration; -import java.util.Collection; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; - -final class UserServiceImpl implements UserService { - - private static final Duration TIMEOUT = Duration.ofSeconds(2L); - - private final PluginLogger logger; - private final UserCache cache; - private final TopUsersCache topUsersCache; - private final UserRepository repository; - private final BukkitEventCaller eventCaller; - - @Inject - UserServiceImpl( - @NotNull PluginLogger logger, - @NotNull UserCache cache, - @NotNull TopUsersCache topUsersCache, - @NotNull UserRepository repository, - @NotNull BukkitEventCaller eventCaller - ) { - this.logger = Validator.notNull(logger, "logger"); - this.cache = Validator.notNull(cache, "cache"); - this.topUsersCache = Validator.notNull(topUsersCache, "topUsersCache"); - this.repository = Validator.notNull(repository, "repository"); - this.eventCaller = Validator.notNull(eventCaller, "eventCaller"); - } - - @Override - public @NotNull Optional findCachedByUuid(@NotNull UUID uuid) { - Validator.notNull(uuid, "uuid"); - return cache.getUserByUuid(uuid); - } - - @Override - public @NotNull Optional findCachedByName(@NotNull String name) { - Validator.notNull(name, "name"); - return cache.getUserByName(name); - } - - @Override - @Unmodifiable - public @NotNull Collection getCachedUsers() { - return cache.getCache(); // returns unmodifiable - } - - @Override - public @NotNull CompletableFuture> findByUuid(@NotNull UUID uuid) { - Validator.notNull(uuid, "uuid"); - - Optional cached = cache.getUserByUuid(uuid); - if (cached.isPresent()) { - return CompletableFuture.completedFuture(cached); - } - - return repository.findByUuid(uuid) - .orTimeout(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) - .thenApply(opt -> { - opt.ifPresent(cache::cacheUser); - return opt; - }) - .exceptionally(e -> { - logger.error(e, "An error occurred while trying to find user with id %s", uuid); - throw new RuntimeException(e); - }); - } - - @Override - public @NotNull CompletableFuture> findByName(@NotNull String name) { - Validator.notNull(name, "name"); - - Optional cached = cache.getUserByName(name); - if (cached.isPresent()) { - return CompletableFuture.completedFuture(cached); - } - - return repository.findByName(name) - .orTimeout(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) - .thenApply(opt -> { - opt.ifPresent(cache::cacheUser); - return opt; - }) - .exceptionally(e -> { - logger.error(e, "An error occurred while trying to find user with name %s", name); - throw new RuntimeException(e); - }); - } - - @Override - public @NotNull CompletableFuture> findTopByPlayTime(int limit) { - return topUsersCache.getTopByPlayTime(limit); - } - - @Override - public @NotNull CompletableFuture save(@NotNull User user, @NotNull UserSaveReason reason) { - Validator.notNull(user, "user"); - Validator.notNull(reason, "reason"); - - eventCaller.callEvent(new UserPreSaveEvent(user, reason)); - - return repository.save(user) - .orTimeout(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) - .thenApply(saved -> { - eventCaller.callEvent(new UserSaveEvent(user, reason)); - cache.cacheUser(saved); - return saved; - }) - .exceptionally(e -> { - logger.error(e, "Failed to save user %s", user.getUuid()); - throw new RuntimeException(e); - }); - } - - @Override - public @NotNull CompletableFuture deleteByUuid(@NotNull UUID uuid) { - Validator.notNull(uuid, "uuid"); - return repository.deleteByUuid(uuid) - .orTimeout(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) - .thenApply(result -> { - eventCaller.callEvent(new UserDeleteEvent(result)); - if (result.isSuccess()) { - cache.invalidateByUuid(uuid); - } - return result; - }) - .exceptionally(e -> { - logger.error(e, "An error occurred while trying to delete user by uuid %s", uuid); - throw new RuntimeException(e); - }); - } - - @Override - public @NotNull CompletableFuture deleteByName(@NotNull String name) { - Validator.notNull(name, "name"); - return repository.deleteByName(name) - .orTimeout(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) - .thenApply(deleteResult -> { - eventCaller.callEvent(new UserDeleteEvent(deleteResult)); - if (deleteResult.isSuccess()) { - cache.invalidateByName(name); - } - return deleteResult; - }) - .exceptionally(e -> { - logger.error(e, "An error occurred while trying to delete user by name %s", name); - throw new RuntimeException(e); - }); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/cache/CaffeineUserCache.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/cache/CaffeineUserCache.java deleted file mode 100644 index cc45992..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/cache/CaffeineUserCache.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.github.imdmk.playtime.user.cache; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import com.github.benmanes.caffeine.cache.RemovalCause; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Unmodifiable; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.function.Consumer; - -/** - * Caffeine-based implementation of {@link UserCache} with dual indexing by UUID and name. - * Entries expire after a period of inactivity and after a maximum lifetime. - */ -public final class CaffeineUserCache implements UserCache { - - private static final Duration DEFAULT_EXPIRE_AFTER_ACCESS = Duration.ofHours(2); - private static final Duration DEFAULT_EXPIRE_AFTER_WRITE = Duration.ofHours(12); - - private final Cache cacheByUuid; - private final Cache cacheByName; - - public CaffeineUserCache(@NotNull Duration expireAfterAccess, @NotNull Duration expireAfterWrite) { - Validator.notNull(expireAfterAccess, "expireAfterAccess"); - Validator.notNull(expireAfterWrite, "expireAfterWrite"); - - this.cacheByName = Caffeine.newBuilder() - .expireAfterWrite(expireAfterWrite) - .expireAfterAccess(expireAfterAccess) - .build(); - - this.cacheByUuid = Caffeine.newBuilder() - .expireAfterWrite(expireAfterWrite) - .expireAfterAccess(expireAfterAccess) - .removalListener((UUID key, User user, RemovalCause cause) -> { - if (key != null && user != null) { - this.cacheByName.invalidate(user.getName()); - } - }) - .build(); - } - - public CaffeineUserCache() { - this(DEFAULT_EXPIRE_AFTER_ACCESS, DEFAULT_EXPIRE_AFTER_WRITE); - } - - @Override - public void cacheUser(@NotNull User user) { - Validator.notNull(user, "user"); - - final UUID uuid = user.getUuid(); - final String name = user.getName(); - - final User previous = cacheByUuid.getIfPresent(uuid); - if (previous != null) { - final String oldName = previous.getName(); - if (!oldName.equals(name)) { - cacheByName.invalidate(oldName); - } - } - - cacheByUuid.put(uuid, user); - cacheByName.put(name, uuid); - } - - @Override - public void invalidateUser(@NotNull User user) { - Validator.notNull(user, "user"); - - cacheByUuid.invalidate(user.getUuid()); - cacheByName.invalidate(user.getName()); - } - - @Override - public void invalidateByUuid(@NotNull UUID uuid) { - Validator.notNull(uuid, "uuid"); - - final User cached = cacheByUuid.getIfPresent(uuid); - cacheByUuid.invalidate(uuid); - if (cached != null) { - cacheByName.invalidate(cached.getName()); - } - } - - @Override - public void invalidateByName(@NotNull String name) { - Validator.notNull(name, "name"); - - final UUID uuid = cacheByName.getIfPresent(name); - if (uuid != null) { - invalidateByUuid(uuid); - } else { - cacheByName.invalidate(name); - } - } - - @Override - public @NotNull Optional getUserByUuid(@NotNull UUID uuid) { - Validator.notNull(uuid, "uuid"); - return Optional.ofNullable(cacheByUuid.getIfPresent(uuid)); - } - - @Override - public @NotNull Optional getUserByName(@NotNull String name) { - Validator.notNull(name, "name"); - - final UUID uuid = cacheByName.getIfPresent(name); - return uuid == null ? Optional.empty() : Optional.ofNullable(cacheByUuid.getIfPresent(uuid)); - } - - @Override - public void updateUserNameMapping(@NotNull User user, @NotNull String oldName) { - Validator.notNull(user, "user cannot be null"); - Validator.notNull(oldName, "oldName cannot be null"); - - final String newName = user.getName(); - if (!oldName.equals(newName)) { - cacheByName.invalidate(oldName); - cacheByName.put(newName, user.getUuid()); - } - - cacheByUuid.put(user.getUuid(), user); - } - - @Override - public void forEachUser(@NotNull Consumer action) { - Validator.notNull(action, "action cannot be null"); - - // Snapshot to avoid iterating over a live view while mutating the cache - for (final User user : new ArrayList<>(cacheByUuid.asMap().values())) { - action.accept(user); - } - } - - @Override - @NotNull - @Unmodifiable - public Collection getCache() { - return List.copyOf(cacheByUuid.asMap().values()); - } - - @Override - public void invalidateAll() { - cacheByUuid.invalidateAll(); - cacheByName.invalidateAll(); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/cache/UserCache.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/cache/UserCache.java deleted file mode 100644 index 109c1e0..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/cache/UserCache.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.github.imdmk.playtime.user.cache; - -import com.github.imdmk.playtime.user.User; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Unmodifiable; - -import java.util.Collection; -import java.util.Optional; -import java.util.UUID; -import java.util.function.Consumer; - -/** - * Cache for {@link User} aggregates. - *

- * Implementations are expected to maintain a fast lookup by both UUID and name, - * keep mappings consistent during updates, and guarantee thread-safety - * if used in a concurrent environment. - */ -public interface UserCache { - - /** - * Adds or replaces the given user in the cache. - * - * @param user the user instance to store - */ - void cacheUser(@NotNull User user); - - /** - * Removes the given user from the cache, if present. - * - * @param user the user instance to remove - */ - void invalidateUser(@NotNull User user); - - /** - * Removes a cached user by UUID. - * - * @param uuid the UUID to remove - */ - void invalidateByUuid(@NotNull UUID uuid); - - /** - * Removes a cached user by name (case-insensitive matching recommended). - * - * @param name the username to remove - */ - void invalidateByName(@NotNull String name); - - /** - * Retrieves a user by UUID. - * - * @param uuid the UUID to search - * @return an {@link Optional} containing the cached user, if present - */ - @NotNull Optional getUserByUuid(@NotNull UUID uuid); - - /** - * Retrieves a user by name. - * - * @param name the username to search - * @return an {@link Optional} containing the cached user, if present - */ - @NotNull Optional getUserByName(@NotNull String name); - - /** - * Updates internal name mappings for users whose username has changed. - * - * @param user the user instance with the new name - * @param oldName the previous username - */ - void updateUserNameMapping(@NotNull User user, @NotNull String oldName); - - /** - * Iterates over all cached users and executes the given action. - * - * @param action the callback executed for each cached user - */ - void forEachUser(@NotNull Consumer action); - - /** - * Returns a view of all cached users. - * - * @return an unmodifiable collection of all cached users - */ - @NotNull @Unmodifiable - Collection getCache(); - - /** - * Clears the entire cache. - */ - void invalidateAll(); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/listener/UserJoinListener.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/listener/UserJoinListener.java deleted file mode 100644 index 1671fa0..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/listener/UserJoinListener.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.github.imdmk.playtime.user.listener; - -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.platform.scheduler.TaskScheduler; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserFactory; -import com.github.imdmk.playtime.user.UserSaveReason; -import com.github.imdmk.playtime.user.UserService; -import org.bukkit.Server; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.event.server.ServerLoadEvent; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.time.Duration; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.TimeUnit; - -public final class UserJoinListener implements Listener { - - private static final Duration FIND_TIMEOUT = Duration.ofSeconds(2); - private static final Duration SAVE_TIMEOUT = Duration.ofSeconds(2); - private static final UserSaveReason SAVE_REASON = UserSaveReason.PLAYER_JOIN; - - private final Server server; - private final PluginLogger logger; - private final UserService userService; - private final UserFactory userFactory; - private final TaskScheduler taskScheduler; - - @Inject - public UserJoinListener( - @NotNull Server server, - @NotNull PluginLogger logger, - @NotNull UserService userService, - @NotNull UserFactory userFactory, - @NotNull TaskScheduler taskScheduler - ) { - this.server = Validator.notNull(server, "server"); - this.logger = Validator.notNull(logger, "logger"); - this.userService = Validator.notNull(userService, "userService"); - this.userFactory = Validator.notNull(userFactory, "userFactory cannot be null"); - this.taskScheduler = Validator.notNull(taskScheduler, "taskScheduler cannot be null"); - } - - @EventHandler(priority = EventPriority.LOWEST) - public void onPlayerJoin(PlayerJoinEvent event) { - handlePlayerJoin(event.getPlayer()); - } - - @EventHandler(priority = EventPriority.HIGHEST) - public void onServerReload(ServerLoadEvent event) { - server.getOnlinePlayers().forEach(this::handlePlayerJoin); - } - - private void handlePlayerJoin(Player player) { - final UUID uuid = player.getUniqueId(); - - userService.findByUuid(uuid) - .orTimeout(FIND_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) - .whenComplete((optional, e) -> { - if (e != null) { - logger.error(e, "Failed to load user on join uuid=%s", uuid); - return; - } - - taskScheduler.runSync(() -> handleLoadedUser(player, optional)); - }); - } - - private void handleLoadedUser(Player player, Optional optionalUser) { - optionalUser.ifPresentOrElse( - user -> handleExistingUser(player, user), - () -> handleNewUser(player) - ); - } - - private void handleNewUser(Player player) { - final User user = userFactory.createFrom(player); - saveUser(user, "on join (new)"); - } - - private void handleExistingUser(Player player, User user) { - final String name = player.getName(); - - if (!updateNameIfChanged(user, name)) { - return; - } - - saveUser(user, "on join (update name)"); - } - - private void saveUser(User user, String context) { - userService.save(user, SAVE_REASON) - .orTimeout(SAVE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) - .whenComplete((r, e) -> { - if (e != null) { - logger.error(e, "Failed to save user %s uuid=%s", context, user.getUuid()); - } - }); - } - - private boolean updateNameIfChanged(User user, String name) { - final String oldName = user.getName(); - if (oldName.equals(name)) { - return false; - } - - user.setName(name); - return true; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/listener/UserQuitListener.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/listener/UserQuitListener.java deleted file mode 100644 index b2ba6b4..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/listener/UserQuitListener.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.github.imdmk.playtime.user.listener; - -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.UserSaveReason; -import com.github.imdmk.playtime.user.UserService; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerQuitEvent; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.time.Duration; -import java.util.UUID; -import java.util.concurrent.TimeUnit; - -public final class UserQuitListener implements Listener { - - private static final Duration SAVE_TIMEOUT = Duration.ofSeconds(2); - private static final UserSaveReason SAVE_REASON = UserSaveReason.PLAYER_LEAVE; - - private final PluginLogger logger; - private final UserService userService; - - @Inject - public UserQuitListener(@NotNull PluginLogger logger, @NotNull UserService userService) { - this.logger = Validator.notNull(logger, "logger"); - this.userService = Validator.notNull(userService, "userService"); - } - - @EventHandler(priority = EventPriority.HIGHEST) - public void onPlayerQuit(PlayerQuitEvent event) { - final Player player = event.getPlayer(); - - final UUID uuid = player.getUniqueId(); - final String name = player.getName(); - - userService.findCachedByUuid(uuid).ifPresent(user -> userService.save(user, SAVE_REASON) - .orTimeout(SAVE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) - .whenComplete((u, e) -> { - if (e != null) { - logger.error(e, "Failed to save user on quit %s (%s)", name, uuid); - } - }) - ); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserEntity.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserEntity.java deleted file mode 100644 index f9884ab..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserEntity.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.github.imdmk.playtime.user.repository; - -import com.j256.ormlite.field.DatabaseField; -import com.j256.ormlite.table.DatabaseTable; -import org.jetbrains.annotations.NotNull; - -import java.util.Objects; -import java.util.UUID; - -/** - * Persistent representation of a user stored in the database. - *

- * This entity is managed by ORMLite and maps directly to the "spent_time_users" table. - * It mirrors the in-memory {@code User} object used in runtime logic. - */ -@DatabaseTable(tableName = UserEntityMeta.TABLE) -public final class UserEntity { - - /** Primary key — unique player UUID. */ - @DatabaseField(id = true, canBeNull = false, columnName = UserEntityMeta.Col.UUID) - private UUID uuid; - - /** Last known player name. */ - @DatabaseField(canBeNull = false, index = true, columnName = UserEntityMeta.Col.NAME) - private String name; - - /** Total spent time in milliseconds. */ - @DatabaseField(canBeNull = false, columnName = UserEntityMeta.Col.PLAYTIME_MILLIS) - private long playtimeMillis; - - /** No-arg constructor required by ORMLite. */ - public UserEntity() {} - - public UserEntity(@NotNull UUID uuid, @NotNull String name, long playtimeMillis) { - this.uuid = uuid; - this.name = name; - this.playtimeMillis = playtimeMillis; - } - - public @NotNull UUID getUuid() { - return this.uuid; - } - - public void setUuid(@NotNull UUID uuid) { - this.uuid = uuid; - } - - public @NotNull String getName() { - return this.name; - } - - public void setName(@NotNull String name) { - this.name = name; - } - - public long getPlaytimeMillis() { - return playtimeMillis; - } - - public void setPlaytimeMillis(long playtimeMillis) { - this.playtimeMillis = playtimeMillis; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof UserEntity other)) return false; - return this.uuid.equals(other.uuid); - } - - @Override - public int hashCode() { - return Objects.hash(this.uuid); - } - - @Override - public String toString() { - return "UserEntity{" + - "uuid=" + this.uuid + - ", name='" + this.name + '\'' + - ", spentMillis=" + this.playtimeMillis + - '}'; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserEntityMapper.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserEntityMapper.java deleted file mode 100644 index 2cb2865..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserEntityMapper.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.imdmk.playtime.user.repository; - -import com.github.imdmk.playtime.infrastructure.database.repository.ormlite.EntityMapper; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserTime; -import org.jetbrains.annotations.NotNull; - -/** - * Maps between the persistent {@link UserEntity} and the in-memory {@link User}. - */ -public final class UserEntityMapper implements EntityMapper { - - @Override - public @NotNull UserEntity toEntity(@NotNull User user) { - Validator.notNull(user, "user"); - return new UserEntity( - user.getUuid(), - user.getName(), - user.getPlaytime().millis() - ); - } - - @Override - public @NotNull User toDomain(@NotNull UserEntity entity) { - Validator.notNull(entity, "entity"); - return new User( - entity.getUuid(), - entity.getName(), - UserTime.ofMillis(entity.getPlaytimeMillis()) - ); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserEntityMeta.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserEntityMeta.java deleted file mode 100644 index 9bdb70b..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserEntityMeta.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.imdmk.playtime.user.repository; - -import com.github.imdmk.playtime.infrastructure.database.repository.ormlite.EntityMeta; - -/** - * Database metadata for the {@code advanced_playtime_users} table. - * - *

This interface defines the table name and all column identifiers used by - * {@link UserEntity} and the corresponding repository implementation.

- * - *

Centralizing these names ensures consistency across entity mappings, - * DAO queries, migrations, and schema creation routines.

- */ -interface UserEntityMeta extends EntityMeta { - - /** Name of the table storing persistent user records. */ - String TABLE = "advanced_playtime_users"; - - /** - * Column name definitions for {@link UserEntity}. - * - *

All constants represent physical column names in the database schema.

- */ - interface Col { - - /** Unique player identifier (primary key, NOT NULL). */ - String UUID = "uuid"; - - /** Last known player name (NOT NULL, indexed). */ - String NAME = "name"; - - /** Total accumulated playtime in milliseconds (NOT NULL). */ - String PLAYTIME_MILLIS = "playtimeMillis"; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserRepository.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserRepository.java deleted file mode 100644 index a7cc1bc..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserRepository.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.github.imdmk.playtime.user.repository; - -import com.github.imdmk.playtime.infrastructure.database.repository.Repository; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserDeleteResult; -import org.jetbrains.annotations.NotNull; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; - -/** - * Asynchronous repository for managing and querying {@link User} records. - *

All methods return non-null {@link CompletableFuture}s and complete exceptionally on failure.

- *

Name matching policy should be documented by the implementation (recommended: case-insensitive, normalized).

- */ -public interface UserRepository extends Repository { - - /** - * Finds a user by UUID. - * - * @param uuid non-null UUID - * @return non-null future with an Optional user (empty if not found) - */ - @NotNull CompletableFuture> findByUuid(@NotNull UUID uuid); - - /** - * Finds a user by exact name (implementation should document case handling). - * - * @param name non-null username - * @return non-null future with an Optional user (empty if not found) - */ - @NotNull CompletableFuture> findByName(@NotNull String name); - - /** - * Retrieves all users from the data source. - *

- * The returned list order is implementation-defined unless otherwise documented. - * Implementations are expected to return an immutable, non-null list, and may apply - * internal caching or batching strategies for performance. - *

- * - * @return non-null future with all persisted users (possibly empty) - */ - @NotNull CompletableFuture> findAll(); - - /** - * Returns top users by spent time, sorted descending. - * Ties are resolved deterministically (e.g., by UUID ascending). - * - * @param limit number of users to return; must be > 0 - * @return non-null future with an immutable list (possibly empty) - */ - @NotNull CompletableFuture> findTopByPlayTime(long limit); - - /** - * Creates or updates the user (upsert). - * - * @param user non-null user - * @return non-null future with the persisted user - */ - @NotNull CompletableFuture save(@NotNull User user); - - /** - * Deletes a user by UUID. - * - * @param uuid non-null UUID - * @return non-null future that completes with {@code true} if a row was deleted, otherwise {@code false} - */ - @NotNull CompletableFuture deleteByUuid(@NotNull UUID uuid); - - /** - * Deletes a user by name. - * - * @param name non-null username - * @return non-null future that completes with {@code true} if a row was deleted, otherwise {@code false} - */ - @NotNull CompletableFuture deleteByName(@NotNull String name); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserRepositoryOrmLite.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserRepositoryOrmLite.java deleted file mode 100644 index 0b891e5..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/repository/UserRepositoryOrmLite.java +++ /dev/null @@ -1,172 +0,0 @@ -package com.github.imdmk.playtime.user.repository; - -import com.github.imdmk.playtime.infrastructure.database.repository.RepositoryContext; -import com.github.imdmk.playtime.infrastructure.database.repository.ormlite.BaseDaoRepository; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserDeleteResult; -import com.github.imdmk.playtime.user.UserDeleteStatus; -import org.jetbrains.annotations.NotNull; -import org.panda_lang.utilities.inject.annotations.Inject; - -import java.sql.SQLException; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; - -public final class UserRepositoryOrmLite - extends BaseDaoRepository - implements UserRepository { - - private final PluginLogger logger; - private final UserEntityMapper mapper; - - @Inject - public UserRepositoryOrmLite( - @NotNull PluginLogger logger, - @NotNull RepositoryContext context, - @NotNull UserEntityMapper mapper - ) { - super(logger, context); - this.logger = Validator.notNull(logger, "logger"); - this.mapper = Validator.notNull(mapper, "mapper"); - } - - @Override - protected Class entityClass() { - return UserEntity.class; - } - - @Override - protected List> entitySubClasses() { - return List.of(); - } - - @Override - public @NotNull CompletableFuture> findByUuid(@NotNull UUID uuid) { - Validator.notNull(uuid, "uuid"); - return executeAsync(() -> { - try { - return Optional.ofNullable(dao.queryForId(uuid)) - .map(mapper::toDomain); - } catch (SQLException e) { - logger.error(e, "Failed to find user by uuid: %s", uuid); - throw new RuntimeException(e); - } - }); - } - - @Override - public @NotNull CompletableFuture> findByName(@NotNull String name) { - Validator.notNull(name, "name"); - return executeAsync(() -> { - try { - UserEntity entity = dao.queryBuilder() - .where().eq(UserEntityMeta.Col.NAME, name) - .queryForFirst(); - return Optional.ofNullable(entity).map(mapper::toDomain); - } catch (SQLException e) { - logger.error(e, "Failed to find user by name: %s", name); - throw new RuntimeException(e); - } - }); - } - - @Override - public @NotNull CompletableFuture> findAll() { - return executeAsync(() -> { - try { - return mapper.toDomainList(dao.queryForAll()); - } catch (SQLException e) { - logger.error(e, "Failed to find all users"); - throw new RuntimeException(e); - } - }); - } - - @Override - public @NotNull CompletableFuture> findTopByPlayTime(long limit) { - if (limit <= 0) { - return CompletableFuture.completedFuture(List.of()); - } - - return executeAsync(() -> { - try { - return mapper.toDomainList( - dao.queryBuilder() - .orderBy(UserEntityMeta.Col.PLAYTIME_MILLIS, false) // DESC - .orderBy(UserEntityMeta.Col.UUID, true) // deterministic tiebreaker - .limit(limit) - .query() - ); - } catch (SQLException e) { - logger.error(e, "Failed to find top users (limit %s) by spent time", limit); - throw new RuntimeException(e); - } - }); - } - - @Override - public @NotNull CompletableFuture save(@NotNull User user) { - Validator.notNull(user, "user"); - return executeAsync(() -> { - try { - dao.createOrUpdate(mapper.toEntity(user)); - return user; - } catch (SQLException e) { - logger.error(e, "Failed to save user: %s", user.getUuid()); - throw new RuntimeException(e); - } - }); - } - - @Override - public @NotNull CompletableFuture deleteByUuid(@NotNull UUID uuid) { - Validator.notNull(uuid, "uuid"); - return executeAsync(() -> { - try { - UserEntity userEntity = dao.queryForId(uuid); - if (userEntity == null) { - return new UserDeleteResult(null, UserDeleteStatus.NOT_FOUND); - } - - User user = mapper.toDomain(userEntity); - - int rows = dao.deleteById(uuid); - return rows > 0 - ? new UserDeleteResult(user, UserDeleteStatus.DELETED) - : new UserDeleteResult(user, UserDeleteStatus.FAILED); - } catch (SQLException e) { - logger.error(e, "Failed to delete user by uuid: %s", uuid); - throw new RuntimeException(e); - } - }); - } - - @Override - public @NotNull CompletableFuture deleteByName(@NotNull String name) { - Validator.notNull(name, "name"); - return executeAsync(() -> { - try { - UserEntity userEntity = dao.queryBuilder() - .where().eq(UserEntityMeta.Col.NAME, name) - .queryForFirst(); - if (userEntity == null) { - return new UserDeleteResult(null, UserDeleteStatus.NOT_FOUND); - } - - User user = mapper.toDomain(userEntity); - - int rows = dao.delete(userEntity); - return rows > 0 - ? new UserDeleteResult(user, UserDeleteStatus.DELETED) - : new UserDeleteResult(user, UserDeleteStatus.FAILED); - } catch (SQLException e) { - logger.error(e, "Failed to delete user by name: %s", name); - throw new RuntimeException(e); - } - }); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/CachedLeaderboard.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/CachedLeaderboard.java deleted file mode 100644 index 5f1ce6e..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/CachedLeaderboard.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.github.imdmk.playtime.user.top; - -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Unmodifiable; - -import java.time.Duration; -import java.time.Instant; -import java.util.List; - -/** - * Immutable representation of a cached leaderboard snapshot. - *

- * Holds the ordered user list, the limit used during loading, and the timestamp - * when the data was retrieved. Provides logic for determining whether the snapshot - * is still valid for a given request. - */ -record CachedLeaderboard( - @NotNull @Unmodifiable List users, - int limit, - @NotNull Instant loadedAt -) { - - /** - * Constructs a new leaderboard snapshot. - * A defensive copy of the user list is created to ensure immutability. - */ - CachedLeaderboard { - Validator.notNull(users, "users"); - Validator.notNull(loadedAt, "loadedAt"); - users = List.copyOf(users); - } - - /** - * Determines whether this leaderboard is valid for the requested limit and expiration policy. - * - * @param requestedLimit limit requested by the caller - * @param expireAfter duration after which the snapshot becomes stale - * @param now current time reference - * @return {@code true} if the leaderboard is fresh and large enough, otherwise {@code false} - */ - boolean isUsable(int requestedLimit, @NotNull Duration expireAfter, @NotNull Instant now) { - Validator.notNull(now, "now"); - - if (this.limit < requestedLimit) { - return false; - } - - if (expireAfter.isZero() || expireAfter.isNegative()) { - return true; - } - - Instant expiresAt = this.loadedAt.plus(expireAfter); - return expiresAt.isAfter(now); - } - - /** - * Returns the ordered user list. - * This list is unmodifiable and safe to expose. - * - * @return immutable list of users - */ - @Override - @Unmodifiable - public @NotNull List users() { - return users; - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/MemoryTopUsersCache.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/MemoryTopUsersCache.java deleted file mode 100644 index 1f1d5df..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/MemoryTopUsersCache.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.github.imdmk.playtime.user.top; - -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.github.imdmk.playtime.shared.validate.Validator; -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.repository.UserRepository; -import org.jetbrains.annotations.NotNull; - -import java.time.Duration; -import java.time.Instant; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -public final class MemoryTopUsersCache implements TopUsersCache { - - private final PluginLogger logger; - private final TopUsersCacheConfig config; - private final UserRepository userRepository; - - private final AtomicReference cachedLeaderboard = new AtomicReference<>(); - - public MemoryTopUsersCache( - @NotNull PluginLogger logger, - @NotNull TopUsersCacheConfig config, - @NotNull UserRepository userRepository - ) { - this.logger = Validator.notNull(logger, "logger"); - this.config = Validator.notNull(config, "config"); - this.userRepository = Validator.notNull(userRepository, "userRepository"); - } - - @Override - public CompletableFuture> getTopByPlayTime() { - return getTopByPlayTime(config.topUsersQueryLimit); - } - - @Override - public @NotNull CompletableFuture> getTopByPlayTime(int limit) { - if (limit <= 0) { - return CompletableFuture.completedFuture(List.of()); - } - - final CachedLeaderboard cached = cachedLeaderboard.get(); - final Duration expireAfter = config.topUsersCacheExpireAfter; - final Instant now = Instant.now(); - - if (cached != null && cached.isUsable(limit, expireAfter, now)) { - return CompletableFuture.completedFuture(slice(cached.users(), limit)); - } - - return loadTop(limit) - .thenApply(users -> { - CachedLeaderboard updated = new CachedLeaderboard(users, limit, Instant.now()); - cachedLeaderboard.set(updated); - return slice(updated.users(), limit); - }) - .exceptionally(e -> { - logger.error(e, "Failed to load users top leaderboard (limit=%d)", limit); - return List.of(); - }); - } - - - @Override - public void invalidateAll() { - cachedLeaderboard.set(null); - } - - private CompletableFuture> loadTop(int limit) { - return userRepository.findTopByPlayTime(limit) - .orTimeout(config.topUsersQueryTimeout.toMillis(), TimeUnit.MILLISECONDS); - } - - private static List slice(List users, int limit) { - return users.size() <= limit ? users : users.subList(0, limit); - } -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/TopUsersCache.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/TopUsersCache.java deleted file mode 100644 index e840a2c..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/TopUsersCache.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.github.imdmk.playtime.user.top; - -import com.github.imdmk.playtime.user.User; - -import java.util.List; -import java.util.concurrent.CompletableFuture; - -/** - * Cache abstraction for retrieving the top users by spent time. - *

- * Implementations are responsible for storing and serving leaderboard data, - * including cache invalidation and optional limit-based slicing. - */ -public interface TopUsersCache { - - /** - * Returns the cached or freshly loaded leaderboard using the default limit - * defined in the cache configuration. - * - * @return future containing an ordered list of top users - */ - CompletableFuture> getTopByPlayTime(); - - /** - * Returns the cached or freshly loaded leaderboard limited to the given size. - * Implementations may slice an existing cached leaderboard or trigger a reload - * if the cache is stale or insufficient for the requested limit. - * - * @param limit maximum number of users to return - * @return future containing an ordered list of top users - */ - CompletableFuture> getTopByPlayTime(int limit); - - /** - * Invalidates all cached leaderboard data. - * Next invocation of {@link #getTopByPlayTime()} will trigger a reload. - */ - void invalidateAll(); -} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/TopUsersCacheConfig.java b/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/TopUsersCacheConfig.java deleted file mode 100644 index 2762395..0000000 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/user/top/TopUsersCacheConfig.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.github.imdmk.playtime.user.top; - -import com.github.imdmk.playtime.config.ConfigSection; -import eu.okaeri.configs.annotation.Comment; -import eu.okaeri.configs.serdes.OkaeriSerdesPack; -import org.jetbrains.annotations.NotNull; - -import java.time.Duration; - -public final class TopUsersCacheConfig extends ConfigSection { - - @Comment({ - "#", - "# Maximum number of top users fetched from the storage when building the leaderboard.", - "# This defines how many players will appear in the Top Playtime ranking.", - "#", - "# Notes:", - "# - Querying more players increases DB load but gives smoother scrolling if GUI supports it.", - "# - 30–50 is usually optimal for most servers.", - "#" - }) - public int topUsersQueryLimit = 30; - - @Comment({ - "#", - "# Duration after which the cached Top Users leaderboard expires.", - "#", - "# How it works:", - "# - When the first player opens /playtime top (or any top-playtime GUI/command),", - "# the plugin loads top users from the DB and caches the result.", - "# - Subsequent calls read the cache instantly (no DB hit).", - "# - After this duration passes, the cache auto-invalidates and the next request reloads it.", - "#", - "# Recommended:", - "# 5-15m depending on server size.", - "#" - }) - public Duration topUsersCacheExpireAfter = Duration.ofMinutes(10); - - @Comment({ - "#", - "# Maximum allowed time to execute the database query that loads the Top Users.", - "#", - "# If the DB does not return results within this time window,", - "# the fetch attempt is cancelled and the plugin will return an error message", - "# instead of blocking the main thread or freezing the server.", - "#", - "# Notes:", - "# - Keep this low. 2–5 seconds is a safe range.", - "# - Protects the server from slow/no-response database calls.", - "#" - }) - public Duration topUsersQueryTimeout = Duration.ofSeconds(3); - - @Override - public @NotNull OkaeriSerdesPack getSerdesPack() { - return registry -> {}; - } - - @Override - public @NotNull String getFileName() { - return "leaderboardConfig.yml"; - } -} diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/database/driver/DriverConfigurerFactoryTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/database/driver/DriverConfigurerFactoryTest.java deleted file mode 100644 index 8aedc8c..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/database/driver/DriverConfigurerFactoryTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.imdmk.playtime.database.driver; - -import com.github.imdmk.playtime.infrastructure.database.DatabaseMode; -import com.github.imdmk.playtime.infrastructure.database.driver.configurer.DriverConfigurerFactory; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatNullPointerException; - -class DriverConfigurerFactoryTest { - - @Test - void shouldReturnConfigurerForEachSupportedMode() { - for (DatabaseMode mode : DatabaseMode.values()) { - assertThatCode(() -> DriverConfigurerFactory.getFor(mode)) - .doesNotThrowAnyException(); - } - } - - @Test - void shouldRejectNullMode() { - assertThatNullPointerException() - .isThrownBy(() -> DriverConfigurerFactory.getFor(null)); - } - - @Test - void shouldThrowForUnsupportedMode() { - // Just to be sure — if enum expands in future - // create invalid fake enum value - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> DriverConfigurerFactory.getFor(DatabaseMode.valueOf("NON_EXISTENT"))); // if ever added - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/database/repository/RepositoryManagerTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/database/repository/RepositoryManagerTest.java deleted file mode 100644 index 498b585..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/database/repository/RepositoryManagerTest.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.github.imdmk.playtime.database.repository; - -import com.github.imdmk.playtime.infrastructure.database.repository.Repository; -import com.github.imdmk.playtime.infrastructure.database.repository.RepositoryManager; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.j256.ormlite.support.ConnectionSource; -import org.junit.jupiter.api.Test; - -import java.sql.SQLException; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -class RepositoryManagerTest { - - @Test - void registerShouldStoreRepository() { - PluginLogger logger = mock(PluginLogger.class); - Repository repo = mock(Repository.class); - - RepositoryManager manager = new RepositoryManager(logger); - - manager.register(repo); - - // Registering same repo twice should log warning - manager.register(repo); - - verify(logger).warn( - eq("Repository %s already registered — skipping"), - eq(repo.getClass().getSimpleName()) - ); - } - - @Test - void startAllShouldInvokeStartOnEachRepository() throws Exception { - PluginLogger logger = mock(PluginLogger.class); - Repository repo1 = mock(Repository.class); - Repository repo2 = mock(Repository.class); - ConnectionSource source = mock(ConnectionSource.class); - - RepositoryManager manager = new RepositoryManager(logger); - manager.register(repo1, repo2); - - manager.startAll(source); - - verify(repo1).start(source); - verify(repo2).start(source); - } - - @Test - void startAllShouldLogAndRethrowSQLException() throws Exception { - PluginLogger logger = mock(PluginLogger.class); - Repository faulty = mock(Repository.class); - ConnectionSource source = mock(ConnectionSource.class); - - doThrow(new SQLException("boom")).when(faulty).start(source); - - RepositoryManager manager = new RepositoryManager(logger); - manager.register(faulty); - - assertThatThrownBy(() -> manager.startAll(source)) - .isInstanceOf(SQLException.class); - - verify(logger).error(any(Exception.class), - eq("Failed to start repository: %s"), - eq(faulty.getClass().getSimpleName())); - } - - @Test - void closeShouldInvokeCloseOnEachRepository() { - PluginLogger logger = mock(PluginLogger.class); - Repository repo1 = mock(Repository.class); - Repository repo2 = mock(Repository.class); - - RepositoryManager manager = new RepositoryManager(logger); - manager.register(repo1, repo2); - - manager.close(); - - verify(repo1).close(); - verify(repo2).close(); - } - - @Test - void closeShouldLogWarningWhenRepositoryThrows() { - PluginLogger logger = mock(PluginLogger.class); - Repository repo = mock(Repository.class); - - doThrow(new RuntimeException("err")).when(repo).close(); - - RepositoryManager manager = new RepositoryManager(logger); - manager.register(repo); - - manager.close(); - - verify(logger).warn( - any(Exception.class), - eq("Error while closing repository: %s"), - eq(repo.getClass().getSimpleName()) - ); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/database/repository/ormlite/BaseDaoRepositoryTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/database/repository/ormlite/BaseDaoRepositoryTest.java deleted file mode 100644 index 3e34ea8..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/database/repository/ormlite/BaseDaoRepositoryTest.java +++ /dev/null @@ -1,170 +0,0 @@ -package com.github.imdmk.playtime.database.repository.ormlite; - -import com.github.imdmk.playtime.infrastructure.database.repository.RepositoryContext; -import com.github.imdmk.playtime.infrastructure.database.repository.ormlite.BaseDaoRepository; -import com.github.imdmk.playtime.platform.logger.PluginLogger; -import com.j256.ormlite.dao.Dao; -import org.junit.jupiter.api.Test; - -import java.time.Duration; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeoutException; -import java.util.function.Supplier; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -class BaseDaoRepositoryTest { - - public static class TestDaoRepository extends BaseDaoRepository { - - public TestDaoRepository(PluginLogger logger, RepositoryContext context) { - super(logger, context); - } - - @Override - protected Class entityClass() { - return String.class; - } - - @Override - protected List> entitySubClasses() { - return List.of(); - } - - public R callWithDao(Supplier supplier) { - return withDao(supplier); - } - - public CompletableFuture callExecuteSupplier(Supplier supplier) { - return executeAsync(supplier); - } - - public CompletableFuture callExecuteSupplierTimeout(Supplier supplier, Duration timeout) { - return executeAsync(supplier, timeout); - } - - public CompletableFuture callExecuteRunnable(Runnable runnable) { - return executeAsync(runnable); - } - - public CompletableFuture callExecuteRunnableTimeout(Runnable runnable, Duration timeout) { - return executeAsync(runnable, timeout); - } - - public void setDaoForTest() { - this.dao = mockDao(); - } - - @SuppressWarnings("unchecked") - private Dao mockDao() { - return mock(Dao.class); - } - } - - private ExecutorService executor() { - return Executors.newSingleThreadExecutor(); - } - - @Test - void executeAsyncSupplierShouldReturnValue() { - PluginLogger logger = mock(PluginLogger.class); - RepositoryContext ctx = new RepositoryContext(executor()); - - TestDaoRepository repo = new TestDaoRepository(logger, ctx); - - CompletableFuture future = - repo.callExecuteSupplier(() -> 42); - - assertThat(future.join()).isEqualTo(42); - } - - @Test - void executeAsyncSupplierShouldWrapException() { - PluginLogger logger = mock(PluginLogger.class); - RepositoryContext ctx = new RepositoryContext(executor()); - - TestDaoRepository repo = new TestDaoRepository(logger, ctx); - - CompletableFuture f = - repo.callExecuteSupplier(() -> { - throw new IllegalStateException("boom"); - }); - - assertThatThrownBy(f::join) - .isInstanceOf(CompletionException.class) - .hasCauseInstanceOf(IllegalStateException.class); - - verify(logger).error(any(Throwable.class), eq("Async DAO operation failed")); - } - - @Test - void executeAsyncSupplierShouldTimeout() { - PluginLogger logger = mock(PluginLogger.class); - RepositoryContext ctx = new RepositoryContext(executor()); - - TestDaoRepository repo = new TestDaoRepository(logger, ctx); - - CompletableFuture f = - repo.callExecuteSupplierTimeout(() -> { - try { - Thread.sleep(200); - } catch (InterruptedException ignored) {} - return 1; - }, Duration.ofMillis(50)); - - assertThatThrownBy(f::join) - .isInstanceOf(CompletionException.class) - .hasCauseInstanceOf(TimeoutException.class); - - verify(logger).warn( - eq("Async DAO operation timed out after %s ms"), - eq(50L) - ); - } - - @Test - void executeAsyncRunnableShouldRunSuccessfully() { - PluginLogger logger = mock(PluginLogger.class); - RepositoryContext ctx = new RepositoryContext(executor()); - TestDaoRepository repo = new TestDaoRepository(logger, ctx); - - CompletableFuture f = - repo.callExecuteRunnable(() -> {}); - - assertThat(f).succeedsWithin(Duration.ofSeconds(1)); - } - - @Test - void withDaoShouldThrowIfDaoNotInitialized() { - PluginLogger logger = mock(PluginLogger.class); - RepositoryContext ctx = new RepositoryContext(executor()); - TestDaoRepository repo = new TestDaoRepository(logger, ctx); - - assertThatThrownBy(() -> repo.callWithDao(() -> "x")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("DAO not initialized"); - } - - @Test - void withDaoShouldRunWhenDaoIsAvailable() { - PluginLogger logger = mock(PluginLogger.class); - RepositoryContext ctx = new RepositoryContext(executor()); - TestDaoRepository repo = new TestDaoRepository(logger, ctx); - - repo.setDaoForTest(); - - String result = repo.callWithDao(() -> "OK"); - - assertThat(result).isEqualTo("OK"); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/database/repository/ormlite/EntityMapperTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/database/repository/ormlite/EntityMapperTest.java deleted file mode 100644 index 8e37c4d..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/database/repository/ormlite/EntityMapperTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.github.imdmk.playtime.database.repository.ormlite; - -import com.github.imdmk.playtime.infrastructure.database.repository.ormlite.EntityMapper; -import org.jetbrains.annotations.NotNull; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class EntityMapperTest { - - private static class FakeMapper implements EntityMapper { - - @Override - public @NotNull String toEntity(@NotNull Integer domain) { - return "E" + domain; - } - - @Override - public @NotNull Integer toDomain(String entity) { - return Integer.parseInt(entity.substring(1)); - } - } - - @Test - void toEntityListShouldMapAllDomainObjects() { - EntityMapper mapper = new FakeMapper(); - - List domain = List.of(1, 2, 3); - List entities = mapper.toEntityList(domain); - - assertThat(entities).containsExactly("E1", "E2", "E3"); - } - - @Test - void toDomainListShouldMapAllEntities() { - EntityMapper mapper = new FakeMapper(); - - List entities = List.of("E5", "E10", "E99"); - List domain = mapper.toDomainList(entities); - - assertThat(domain).containsExactly(5, 10, 99); - } - - @Test - void toEntityListShouldHandleEmptyList() { - EntityMapper mapper = new FakeMapper(); - - List result = mapper.toEntityList(List.of()); - - assertThat(result).isEmpty(); - } - - @Test - void toDomainListShouldHandleEmptyList() { - EntityMapper mapper = new FakeMapper(); - - List result = mapper.toDomainList(List.of()); - - assertThat(result).isEmpty(); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/adventure/AdventureComponentsTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/shared/adventure/AdventureComponentsTest.java deleted file mode 100644 index 5f5aafc..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/adventure/AdventureComponentsTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.github.imdmk.playtime.shared.adventure; - -import com.github.imdmk.playtime.platform.adventure.AdventureComponents; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.TextDecoration; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class AdventureComponentsTest { - - @Test - void text_single_shouldDeserialize() { - Component c = AdventureComponents.text("Hello"); - - assertThat(c) - .extracting(comp -> comp.decoration(TextDecoration.BOLD)) - .isNotNull(); - } - - @Test - void text_varargs_shouldDeserializeList() { - List list = AdventureComponents.text("A", "B"); - - assertThat(list).hasSize(2); - } - - @Test - void text_iterable_shouldDeserializeAll() { - List list = AdventureComponents.text(List.of("X", "Y")); - - assertThat(list).hasSize(2); - } - - @Test - void withoutItalics_component_shouldDisableItalic() { - Component c = AdventureComponents.text("Hello"); - Component result = AdventureComponents.withoutItalics(c); - - assertThat(result.decoration(TextDecoration.ITALIC)).isEqualTo(TextDecoration.State.FALSE); - } - - @Test - void withoutItalics_stringVarargs_shouldDisableItalic() { - List out = AdventureComponents.withoutItalics("A", "B"); - - assertThat(out).hasSize(2); - out.forEach(c -> - assertThat(c.decoration(TextDecoration.ITALIC)).isEqualTo(TextDecoration.State.FALSE)); - } - - @Test - void serialize_shouldReturnMiniMessage() { - Component c = Component.text("Test"); - String s = AdventureComponents.serialize(c); - - assertThat(s).isEqualTo("Test"); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/adventure/AdventureFormatterTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/shared/adventure/AdventureFormatterTest.java deleted file mode 100644 index a332f16..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/adventure/AdventureFormatterTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.github.imdmk.playtime.shared.adventure; - -import com.github.imdmk.playtime.platform.adventure.AdventureFormatter; -import com.github.imdmk.playtime.platform.adventure.AdventurePlaceholders; -import net.kyori.adventure.text.Component; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class AdventureFormatterTest { - - @Test - void format_string_shouldApplyPlaceholder() { - AdventurePlaceholders ph = AdventurePlaceholders.builder() - .with("%name%", "DMK") - .build(); - - Component result = AdventureFormatter.format("Hello %name%", ph); - - assertThat(result.toString()).contains("DMK"); - } - - @Test - void format_component_shouldApplyPlaceholder() { - Component base = Component.text("XP: %xp%"); - AdventurePlaceholders ph = AdventurePlaceholders.builder() - .with("%xp%", "1500") - .build(); - - Component out = AdventureFormatter.format(base, ph); - - assertThat(out.toString()).contains("1500"); - } - - @Test - void format_list_shouldApplyPlaceholdersToAll() { - AdventurePlaceholders ph = AdventurePlaceholders.builder() - .with("%v%", "VALUE") - .build(); - - List out = AdventureFormatter.format( - List.of(Component.text("A %v%"), Component.text("B %v%")), - ph - ); - - assertThat(out).hasSize(2); - assertThat(out.get(0).toString()).contains("VALUE"); - assertThat(out.get(1).toString()).contains("VALUE"); - } - - @Test - void format_shouldHandleOverlappingKeysByLength() { - AdventurePlaceholders ph = AdventurePlaceholders.builder() - .with("%player%", "DOM") - .with("%player_name%", "DMK") - .build(); - - Component base = Component.text("Hello %player_name% !"); - Component out = AdventureFormatter.format(base, ph); - - assertThat(out.toString()).contains("DMK"); - assertThat(out.toString()).doesNotContain("DOM"); - } - - @Test - void format_emptyPlaceholders_shouldReturnSameComponent() { - Component c = Component.text("Test"); - Component out = AdventureFormatter.format(c, AdventurePlaceholders.empty()); - - assertThat(out).isSameAs(c); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/adventure/AdventurePlaceholdersTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/shared/adventure/AdventurePlaceholdersTest.java deleted file mode 100644 index 1788086..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/adventure/AdventurePlaceholdersTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.github.imdmk.playtime.shared.adventure; - -import com.github.imdmk.playtime.platform.adventure.AdventurePlaceholders; -import net.kyori.adventure.text.Component; -import org.junit.jupiter.api.Test; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class AdventurePlaceholdersTest { - - @Test - void empty_shouldReturnSingleton() { - AdventurePlaceholders p1 = AdventurePlaceholders.empty(); - AdventurePlaceholders p2 = AdventurePlaceholders.empty(); - - assertThat(p1).isSameAs(p2); - assertThat(p1.size()).isZero(); - } - - @Test - void builder_withStringAndComponent_shouldStoreMapping() { - Component value = Component.text("Hello"); - AdventurePlaceholders ph = AdventurePlaceholders.builder() - .with("%player%", value) - .build(); - - assertThat(ph.asMap()) - .containsEntry("%player%", value); - } - - @Test - void builder_withStringStringShouldConvertToTextComponent() { - AdventurePlaceholders ph = AdventurePlaceholders.builder() - .with("%x%", "Hello") - .build(); - - Component comp = ph.asMap().get("%x%"); - assertThat(comp).isNotNull(); - assertThat(comp.toString()).contains("Hello"); - } - - @Test - void builder_withObjectShouldConvertToStringComponent() { - AdventurePlaceholders ph = AdventurePlaceholders.builder() - .with("%n%", 123) - .build(); - - Component comp = ph.asMap().get("%n%"); - assertThat(comp.toString()).contains("123"); - } - - @Test - void builder_withOtherPlaceholdersShouldMerge() { - AdventurePlaceholders base = AdventurePlaceholders.builder() - .with("%a%", "A") - .build(); - - AdventurePlaceholders merged = AdventurePlaceholders.builder() - .with("%b%", "B") - .with(base) - .build(); - - assertThat(merged.asMap()) - .containsEntry("%a%", Component.text("A")) - .containsEntry("%b%", Component.text("B")); - } - - @Test - void asMap_shouldBeUnmodifiable() { - AdventurePlaceholders ph = AdventurePlaceholders.builder() - .with("%x%", "1") - .build(); - - Map map = ph.asMap(); - - assertThrows(UnsupportedOperationException.class, () -> map.put("%y%", Component.text("2"))); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationFormatStyleTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationFormatStyleTest.java deleted file mode 100644 index 00c02d6..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationFormatStyleTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.github.imdmk.playtime.shared.time; - -import org.junit.jupiter.api.Test; - -import java.time.Duration; - -import static org.assertj.core.api.Assertions.assertThat; - -class DurationFormatStyleTest { - - @Test - void compact_formatShouldUseAbbreviations() { - String out = DurationFormatStyle.COMPACT.format(Duration.ofSeconds(3665)); - assertThat(out).isEqualTo("1h 1m 5s"); - } - - @Test - void long_formatShouldUseFullNames() { - String out = DurationFormatStyle.LONG.format(Duration.ofMinutes(61)); - assertThat(out).isEqualTo("1 hour 1 minute"); - } - - @Test - void longWithAnd_formatShouldUseAndSeparator() { - String out = DurationFormatStyle.LONG_WITH_AND.format(Duration.ofSeconds(62)); - assertThat(out).isEqualTo("1 minute and 2 seconds"); - } - - @Test - void natural_formatShouldUseCommaSeparator() { - String out = DurationFormatStyle.NATURAL.format(Duration.ofHours(24 + 2)); - assertThat(out).isEqualTo("1 day, 2 hours"); - } - - @Test - void formatWith_noNonZeroUnitsShouldReturnEmpty() { - String out = DurationFormatStyle.NATURAL.format(Duration.ZERO); - assertThat(out).isEmpty(); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationSplitterTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationSplitterTest.java deleted file mode 100644 index c9af105..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationSplitterTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.github.imdmk.playtime.shared.time; - -import org.junit.jupiter.api.Test; - -import java.time.Duration; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -class DurationSplitterTest { - - @Test - void split_shouldReturnCorrectParts() { - Duration d = Duration.ofDays(2) - .plusHours(5) - .plusMinutes(30) - .plusSeconds(10); - - Map parts = DurationSplitter.split(d); - - assertThat(parts.get(DurationUnit.DAY)).isEqualTo(2); - assertThat(parts.get(DurationUnit.HOUR)).isEqualTo(5); - assertThat(parts.get(DurationUnit.MINUTE)).isEqualTo(30); - assertThat(parts.get(DurationUnit.SECOND)).isEqualTo(10); - } - - @Test - void split_zeroDurationShouldReturnAllZero() { - Map parts = DurationSplitter.split(Duration.ZERO); - - assertThat(parts.values()).allMatch(v -> v == 0); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationUnitTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationUnitTest.java deleted file mode 100644 index 853bee5..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationUnitTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.github.imdmk.playtime.shared.time; - -import org.junit.jupiter.api.Test; - -import java.time.Duration; - -import static org.assertj.core.api.Assertions.assertThat; - -class DurationUnitTest { - - @Test - void extract_shouldReturnCorrectValues() { - Duration d = Duration.ofDays(1) - .plusHours(2) - .plusMinutes(3) - .plusSeconds(4); - - assertThat(DurationUnit.DAY.extract(d)).isEqualTo(1); - assertThat(DurationUnit.HOUR.extract(d)).isEqualTo(2); - assertThat(DurationUnit.MINUTE.extract(d)).isEqualTo(3); - assertThat(DurationUnit.SECOND.extract(d)).isEqualTo(4); - } - - @Test - void toDisplayName_shouldUseSingularOrPluralProperly() { - assertThat(DurationUnit.HOUR.toDisplayName(1)).isEqualTo("1 hour"); - assertThat(DurationUnit.HOUR.toDisplayName(5)).isEqualTo("5 hours"); - } - - @Test - void getAbbreviation_shouldReturnCorrectStrings() { - assertThat(DurationUnit.DAY.getAbbreviation()).isEqualTo("d"); - assertThat(DurationUnit.SECOND.getAbbreviation()).isEqualTo("s"); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationsTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationsTest.java deleted file mode 100644 index 0bf8823..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/shared/time/DurationsTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.github.imdmk.playtime.shared.time; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.time.Duration; - -import static org.assertj.core.api.Assertions.assertThat; - -class DurationsTest { - - @BeforeEach - void resetDefaultStyle() { - Durations.setDefaultFormatStyle(DurationFormatStyle.NATURAL); - } - - @Test - void format_zeroOrNegative_shouldReturnLessThanOneSecond() { - assertThat(Durations.format(Duration.ZERO)).isEqualTo("<1s"); - assertThat(Durations.format(Duration.ofMillis(-500))).isEqualTo("<1s"); - } - - @Test - void format_withDefaultStyleShouldUseNatural() { - String result = Durations.format(Duration.ofSeconds(65)); - assertThat(result).isEqualTo("1 minute, 5 seconds"); - } - - @Test - void format_withCustomStyleShouldFormatProperly() { - String result = Durations.format(Duration.ofSeconds(3661), DurationFormatStyle.COMPACT); - assertThat(result).isEqualTo("1h 1m 1s"); - } - - @Test - void setDefaultFormatStyle_shouldAffectGlobalFormatting() { - Durations.setDefaultFormatStyle(DurationFormatStyle.COMPACT); - String result = Durations.format(Duration.ofMinutes(3)); - - assertThat(result).isEqualTo("3m"); - } - - @Test - void clamp_negative_shouldReturnZero() { - assertThat(Durations.clamp(Duration.ofMillis(-1))) - .isEqualTo(Duration.ZERO); - } - - @Test - void clamp_aboveMax_shouldClampToMax() { - Duration tenYears = Duration.ofDays(5000); - Duration clamped = Durations.clamp(tenYears); - - assertThat(clamped).isEqualTo(Duration.ofDays(3650)); - } - - @Test - void clamp_withinRange_shouldReturnSame() { - Duration d = Duration.ofHours(12); - assertThat(Durations.clamp(d)).isEqualTo(d); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/user/cache/CaffeineUserCacheTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/user/cache/CaffeineUserCacheTest.java deleted file mode 100644 index c3b3922..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/user/cache/CaffeineUserCacheTest.java +++ /dev/null @@ -1,154 +0,0 @@ -package com.github.imdmk.playtime.user.cache; - -import com.github.imdmk.playtime.user.User; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.time.Duration; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; - -class CaffeineUserCacheTest { - - private CaffeineUserCache cache; - - @BeforeEach - void setup() { - cache = new CaffeineUserCache( - Duration.ofHours(1), - Duration.ofHours(1) - ); - } - - private static User user(UUID uuid, String name) { - return new User(uuid, name); - } - - @Test - void cacheUser_shouldStoreUserInBothIndexes() { - var u = user(UUID.randomUUID(), "DMK"); - - cache.cacheUser(u); - - assertThat(cache.getUserByUuid(u.getUuid())) - .contains(u); - - assertThat(cache.getUserByName("DMK")) - .contains(u); - } - - @Test - void cacheUser_shouldReplaceOldNameMappingOnNameChange() { - var uuid = UUID.randomUUID(); - var oldUser = user(uuid, "DMK"); - var newUser = user(uuid, "DMKNew"); - - cache.cacheUser(oldUser); - cache.cacheUser(newUser); - - assertThat(cache.getUserByName("DMK")).isEmpty(); - assertThat(cache.getUserByName("DMKNew")).contains(newUser); - } - - @Test - void invalidateUser_shouldRemoveFromBothIndexes() { - var u = user(UUID.randomUUID(), "Player1"); - cache.cacheUser(u); - - cache.invalidateUser(u); - - assertThat(cache.getUserByUuid(u.getUuid())).isEmpty(); - assertThat(cache.getUserByName("Player1")).isEmpty(); - } - - @Test - void invalidateByUuid_shouldRemoveCorrectEntries() { - var u = user(UUID.randomUUID(), "Nick"); - - cache.cacheUser(u); - cache.invalidateByUuid(u.getUuid()); - - assertThat(cache.getUserByUuid(u.getUuid())).isEmpty(); - assertThat(cache.getUserByName("Nick")).isEmpty(); - } - - @Test - void invalidateByName_shouldRemoveCorrectEntries() { - var u = user(UUID.randomUUID(), "Tester"); - - cache.cacheUser(u); - cache.invalidateByName("Tester"); - - assertThat(cache.getUserByUuid(u.getUuid())).isEmpty(); - assertThat(cache.getUserByName("Tester")).isEmpty(); - } - - @Test - void getUserByUuid_shouldReturnEmptyWhenNotFound() { - assertThat(cache.getUserByUuid(UUID.randomUUID())).isEmpty(); - } - - @Test - void getUserByName_shouldReturnEmptyWhenNotFound() { - assertThat(cache.getUserByName("Unknown")).isEmpty(); - } - - @Test - void updateUserNameMapping_shouldUpdateNameIndexProperly() { - var uuid = UUID.randomUUID(); - var oldUser = user(uuid, "A"); - var newUser = user(uuid, "B"); - - cache.cacheUser(oldUser); - cache.updateUserNameMapping(newUser, "A"); - - assertThat(cache.getUserByName("A")).isEmpty(); - assertThat(cache.getUserByName("B")).contains(newUser); - } - - @Test - void forEachUser_shouldIterateAllUsers() { - var u1 = user(UUID.randomUUID(), "One"); - var u2 = user(UUID.randomUUID(), "Two"); - - cache.cacheUser(u1); - cache.cacheUser(u2); - - AtomicInteger counter = new AtomicInteger(); - - cache.forEachUser(u -> counter.incrementAndGet()); - - assertThat(counter.get()).isEqualTo(2); - } - - @Test - void getCache_shouldReturnUnmodifiableSnapshot() { - var u1 = user(UUID.randomUUID(), "One"); - cache.cacheUser(u1); - - var snapshot = cache.getCache(); - - assertThat(snapshot).contains(u1); - assertThatExceptionOfType(UnsupportedOperationException.class) - .isThrownBy(snapshot::clear); - } - - @Test - void invalidateAll_shouldClearEverything() { - var u1 = user(UUID.randomUUID(), "A"); - var u2 = user(UUID.randomUUID(), "B"); - - cache.cacheUser(u1); - cache.cacheUser(u2); - - cache.invalidateAll(); - - assertThat(cache.getCache()).isEmpty(); - assertThat(cache.getUserByName("A")).isEmpty(); - assertThat(cache.getUserByName("B")).isEmpty(); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/user/repository/UserEntityMapperTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/user/repository/UserEntityMapperTest.java deleted file mode 100644 index 0f814b5..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/user/repository/UserEntityMapperTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.github.imdmk.playtime.user.repository; - -import com.github.imdmk.playtime.user.User; -import com.github.imdmk.playtime.user.UserTime; -import org.junit.jupiter.api.Test; - -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class UserEntityMapperTest { - - private final UserEntityMapper mapper = new UserEntityMapper(); - - @Test - void toEntityShouldMapFieldsCorrectly() { - var uuid = UUID.randomUUID(); - var user = new User(uuid, "DMK", UserTime.ofMillis(3000)); - - var entity = mapper.toEntity(user); - - assertThat(entity.getUuid()).isEqualTo(uuid); - assertThat(entity.getName()).isEqualTo("DMK"); - assertThat(entity.getPlaytimeMillis()).isEqualTo(3000); - } - - @Test - void toDomainShouldMapFieldsCorrectly() { - var uuid = UUID.randomUUID(); - var entity = new UserEntity(uuid, "XYZ", 5000); - - var user = mapper.toDomain(entity); - - assertThat(user.getUuid()).isEqualTo(uuid); - assertThat(user.getName()).isEqualTo("XYZ"); - assertThat(user.getPlaytime().millis()).isEqualTo(5000); - } - - @Test - void toEntityShouldRejectNull() { - assertThatThrownBy(() -> mapper.toEntity(null)) - .isInstanceOf(NullPointerException.class); - } - - @Test - void toDomainShouldRejectNull() { - assertThatThrownBy(() -> mapper.toDomain(null)) - .isInstanceOf(NullPointerException.class); - } -} - diff --git a/playtime-core/src/test/java/com/github/imdmk/playtime/user/repository/UserEntityTest.java b/playtime-core/src/test/java/com/github/imdmk/playtime/user/repository/UserEntityTest.java deleted file mode 100644 index 51dbbb9..0000000 --- a/playtime-core/src/test/java/com/github/imdmk/playtime/user/repository/UserEntityTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.github.imdmk.playtime.user.repository; - -import org.junit.jupiter.api.Test; - -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; - -class UserEntityTest { - - @Test - void constructorShouldSetFields() { - var uuid = UUID.randomUUID(); - var entity = new UserEntity(uuid, "DMK", 12345); - - assertThat(entity.getUuid()).isEqualTo(uuid); - assertThat(entity.getName()).isEqualTo("DMK"); - assertThat(entity.getPlaytimeMillis()).isEqualTo(12345); - } - - @Test - void settersShouldUpdateFields() { - var entity = new UserEntity(); - - var uuid = UUID.randomUUID(); - entity.setUuid(uuid); - entity.setName("DMK"); - entity.setPlaytimeMillis(500); - - assertThat(entity.getUuid()).isEqualTo(uuid); - assertThat(entity.getName()).isEqualTo("DMK"); - assertThat(entity.getPlaytimeMillis()).isEqualTo(500); - } - - @Test - void equalsShouldCompareByUuidOnly() { - var uuid = UUID.randomUUID(); - - var a = new UserEntity(uuid, "A", 1); - var b = new UserEntity(uuid, "B", 9999); - - assertThat(a).isEqualTo(b); - assertThat(a.hashCode()).isEqualTo(b.hashCode()); - } - - @Test - void equalsShouldReturnFalseForDifferentUuid() { - var a = new UserEntity(UUID.randomUUID(), "A", 1); - var b = new UserEntity(UUID.randomUUID(), "A", 1); - - assertThat(a).isNotEqualTo(b); - } - - @Test - void toStringShouldContainFields() { - var uuid = UUID.randomUUID(); - var e = new UserEntity(uuid, "X", 123); - - var s = e.toString(); - - assertThat(s).contains("uuid=" + uuid); - assertThat(s).contains("name='X'"); - assertThat(s).contains("spentMillis=123"); - } -} - diff --git a/playtime-plugin/build.gradle.kts b/playtime-plugin/build.gradle.kts index 5b3d067..98100aa 100644 --- a/playtime-plugin/build.gradle.kts +++ b/playtime-plugin/build.gradle.kts @@ -15,7 +15,7 @@ tasks.build { } tasks.withType { - archiveFileName.set("AdvancedPlaytime v${project.version} (MC 1.21).jar") + archiveFileName.set("AdvancedPlayTime v${project.version} (MC 1.21).jar") mergeServiceFiles() @@ -51,10 +51,6 @@ tasks.withType { ).forEach { pkg -> relocate(pkg, "$relocationPrefix.$pkg") } - - minimize { - exclude(dependency("com.github.ben-manes.caffeine:caffeine")) - } } bukkit { diff --git a/playtime-plugin/src/main/java/com/github/imdmk/playtime/LoaderDefaultSettings.java b/playtime-plugin/src/main/java/com/github/imdmk/playtime/LoaderDefaultSettings.java deleted file mode 100644 index a1d98de..0000000 --- a/playtime-plugin/src/main/java/com/github/imdmk/playtime/LoaderDefaultSettings.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.github.imdmk.playtime; - -import com.github.imdmk.playtime.config.ConfigSection; -import com.github.imdmk.playtime.config.PluginConfig; -import com.github.imdmk.playtime.feature.migration.MigrationConfig; -import com.github.imdmk.playtime.feature.migration.MigrationModule; -import com.github.imdmk.playtime.feature.playtime.PlayTimeModule; -import com.github.imdmk.playtime.feature.reload.ReloadModule; -import com.github.imdmk.playtime.infrastructure.database.DatabaseConfig; -import com.github.imdmk.playtime.infrastructure.module.Module; -import com.github.imdmk.playtime.message.MessageConfig; -import com.github.imdmk.playtime.platform.gui.GuiModule; -import com.github.imdmk.playtime.platform.gui.config.GuiConfig; -import com.github.imdmk.playtime.user.UserModule; -import com.github.imdmk.playtime.user.top.TopUsersCacheConfig; -import org.jetbrains.annotations.NotNull; - -import java.util.List; - -/** - * Default bootstrap settings for PlayTime: config sections and plugin modules. - */ -class LoaderDefaultSettings implements LoaderSettings { - - @Override - public @NotNull List> configSections() { - return List.of( - PluginConfig.class, - MessageConfig.class, - DatabaseConfig.class, - GuiConfig.class, - MigrationConfig.class, - TopUsersCacheConfig.class - ); - } - - @Override - public @NotNull List> pluginModules() { - return List.of( - UserModule.class, - PlayTimeModule.class, - GuiModule.class, - MigrationModule.class, - ReloadModule.class - ); - } -} diff --git a/playtime-plugin/src/main/java/com/github/imdmk/playtime/LoaderSettings.java b/playtime-plugin/src/main/java/com/github/imdmk/playtime/LoaderSettings.java deleted file mode 100644 index f649c45..0000000 --- a/playtime-plugin/src/main/java/com/github/imdmk/playtime/LoaderSettings.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.imdmk.playtime; - -import com.github.imdmk.playtime.config.ConfigSection; -import com.github.imdmk.playtime.infrastructure.module.Module; -import org.jetbrains.annotations.NotNull; - -import java.util.List; - -/** - * Defines the bootstrap configuration used by {@link PlayTimePluginLoader}. - * - *

This interface decouples the loader from concrete configuration sources, - * enabling custom setups (testing, profiling, modular distributions, etc.).

- */ -public interface LoaderSettings { - - /** - * Returns a list of all {@link ConfigSection} types that should be registered - * and loaded during plugin bootstrap. - * - * @return non-null list of configuration section classes - */ - @NotNull List> configSections(); - - /** - * Returns the ordered list of {@link Module} classes that define - * the plugin's functional modules (features, services, listeners, commands). - * - * @return non-null list of plugin module classes - */ - @NotNull List> pluginModules(); -} diff --git a/playtime-plugin/src/main/java/com/github/imdmk/playtime/PlayTimeExecutorFactory.java b/playtime-plugin/src/main/java/com/github/imdmk/playtime/PlayTimeExecutorFactory.java deleted file mode 100644 index 5be123c..0000000 --- a/playtime-plugin/src/main/java/com/github/imdmk/playtime/PlayTimeExecutorFactory.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.github.imdmk.playtime; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.time.Duration; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; - -/** - * Factory and utilities for worker executor used by PlayTime. - */ -final class PlayTimeExecutorFactory { - - private static final String WORKER_THREAD_NAME = "AdvancedPlayTime-Worker"; - private static final Duration SHUTDOWN_TIMEOUT = Duration.ofSeconds(3); - - private PlayTimeExecutorFactory() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); - } - - /** - * Creates a dedicated single-threaded worker executor for asynchronous plugin operations. - * The executor uses a named daemon thread ({@code PlayTime-Worker}). - * - * @return configured single-threaded executor service - */ - static @NotNull ExecutorService newWorkerExecutor() { - ThreadFactory factory = runnable -> { - Thread thread = new Thread(runnable, WORKER_THREAD_NAME); - thread.setDaemon(true); - return thread; - }; - - return Executors.newSingleThreadExecutor(factory); - } - - /** - * Shuts down the given executor quietly, awaiting termination for a short period. - * If it fails to terminate gracefully, all running tasks are forcibly cancelled. - * - * @param executor the executor to shut down, may be {@code null} - */ - static void shutdownQuietly(@Nullable ExecutorService executor) { - if (executor == null) { - return; - } - - executor.shutdown(); - try { - if (!executor.awaitTermination(SHUTDOWN_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) { - executor.shutdownNow(); - } - } catch (InterruptedException ie) { - executor.shutdownNow(); - Thread.currentThread().interrupt(); - } - } -} - diff --git a/playtime-plugin/src/main/java/com/github/imdmk/playtime/PlayTimePluginLoader.java b/playtime-plugin/src/main/java/com/github/imdmk/playtime/PlayTimePluginLoader.java index a935cb8..c3a18c9 100644 --- a/playtime-plugin/src/main/java/com/github/imdmk/playtime/PlayTimePluginLoader.java +++ b/playtime-plugin/src/main/java/com/github/imdmk/playtime/PlayTimePluginLoader.java @@ -1,51 +1,23 @@ package com.github.imdmk.playtime; -import com.github.imdmk.playtime.shared.validate.Validator; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; -import org.jetbrains.annotations.NotNull; - -import java.util.concurrent.ExecutorService; - public final class PlayTimePluginLoader extends JavaPlugin { - private final ExecutorService executor; - private final LoaderSettings settings; - - private volatile PlayTimePlugin pluginCore; - - public PlayTimePluginLoader(@NotNull ExecutorService executor, @NotNull LoaderSettings settings) { - this.executor = Validator.notNull(executor, "executor"); - this.settings = Validator.notNull(settings, "settings"); - } + private PlayTimePlugin pluginCore; - public PlayTimePluginLoader() { - this(PlayTimeExecutorFactory.newWorkerExecutor(), new LoaderDefaultSettings()); - } - - /** - * Called by Bukkit when the plugin is being enabled - */ @Override public void onEnable() { final Plugin plugin = this; - - this.pluginCore = new PlayTimePlugin(plugin, executor); - this.pluginCore.enable(settings.configSections(), settings.pluginModules()); + this.pluginCore = new PlayTimePlugin(plugin); } - /** - * Called by Bukkit when the plugin is being disabled, either on server shutdown - * or via manual reload. - */ @Override public void onDisable() { if (this.pluginCore != null) { this.pluginCore.disable(); this.pluginCore = null; } - - PlayTimeExecutorFactory.shutdownQuietly(executor); } }