diff --git a/build.gradle.kts b/build.gradle.kts index e955642..b6013f2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -82,7 +82,7 @@ tasks { // dev → Core-1.0.0-dev.jar archiveBaseName.set("Core") archiveVersion.set(version.toString()) - archiveClassifier.set(if (isDev) "dev" else "") + archiveClassifier.set(if (isDev) "dev-${shortSha}" else "") // ── Relocation ─────────────────────────────────────────────────────── relocate("com.fasterxml.jackson", "dev.mzcy.core.libs.jackson") diff --git a/gradle.properties b/gradle.properties index 873bb7e..ad185de 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,5 +3,4 @@ org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.jvmargs=-Xmx2g -XX:+UseG1GC kotlin.stdlib.default.dependency=false -version=1.0.1 -aaaaaa=b \ No newline at end of file +version=1.0.2 \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/CorePlugin.java b/src/main/java/dev/mzcy/core/CorePlugin.java index d995c02..cc5121f 100644 --- a/src/main/java/dev/mzcy/core/CorePlugin.java +++ b/src/main/java/dev/mzcy/core/CorePlugin.java @@ -4,13 +4,18 @@ import dev.mzcy.core.cache.CacheManager; import dev.mzcy.core.command.CommandManager; import dev.mzcy.core.config.ConfigManager; +import dev.mzcy.core.config.migration.ConfigMigrationManager; import dev.mzcy.core.conversation.ConversationManager; +import dev.mzcy.core.cooldown.CooldownManager; +import dev.mzcy.core.cooldown.PersistentCooldownStore; import dev.mzcy.core.cutscene.CutsceneManager; import dev.mzcy.core.data.DataStoreManager; import dev.mzcy.core.database.DatabaseManager; import dev.mzcy.core.debug.DebugCommand; import dev.mzcy.core.debug.DebugOverlay; import dev.mzcy.core.debug.DebugRegistry; +import dev.mzcy.core.dependency.DependencyCheckResultSet; +import dev.mzcy.core.dependency.DependencyChecker; import dev.mzcy.core.di.Container; import dev.mzcy.core.display.ActionbarManager; import dev.mzcy.core.display.bossbar.BossBarManager; @@ -28,7 +33,10 @@ import dev.mzcy.core.npc.NpcManager; import dev.mzcy.core.placeholder.PlaceholderManager; import dev.mzcy.core.plugin.settings.CoreSettingsConfig; +import dev.mzcy.core.profiling.ProfilingManager; +import dev.mzcy.core.ratelimit.RateLimitManager; import dev.mzcy.core.reload.HotReloadManager; +import dev.mzcy.core.retry.RetryManager; import dev.mzcy.core.scanner.ClassScanner; import dev.mzcy.core.scanner.ComponentRegistry; import dev.mzcy.core.scanner.ScanResult; @@ -38,6 +46,7 @@ import dev.mzcy.core.task.TaskManager; import dev.mzcy.core.updater.UpdateChecker; import dev.mzcy.core.updater.UpdateNotifier; +import dev.mzcy.core.validation.ValidationManager; import lombok.Getter; import lombok.extern.java.Log; import org.bukkit.event.Listener; @@ -85,6 +94,8 @@ public final class CorePlugin extends JavaPlugin { // Framework components // ========================================================================= + @Getter + private DependencyCheckResultSet dependencyCheckResult; @Getter private Container container; @Getter @@ -143,6 +154,12 @@ public final class CorePlugin extends JavaPlugin { private CacheManager cacheManager; @Getter private CutsceneManager cutsceneManager; + @Getter + private CooldownManager cooldownManager; + @Getter private ConfigMigrationManager configMigrationManager; + @Getter private ProfilingManager profilingManager; + @Getter private ValidationManager validationManager; + @Getter private RetryManager retryManager;@Getter private RateLimitManager rateLimitManager; /** * The scan result from startup — available to dependent plugins post-enable. @@ -154,6 +171,30 @@ public final class CorePlugin extends JavaPlugin { // Enable // ========================================================================= + private void checkDependencies() { + dependencyCheckResult = new DependencyChecker(getServer().getPluginManager()) + // Keine required deps für Core selbst — es ist das Framework + .recommend("LuckPerms", + "Permission group support and @RequiresPermission integration") + .recommend("Vault", + "Economy and permissions API fallback") + .recommend("PlaceholderAPI", + "Placeholder support in messages and configs") + .optional("WorldEdit", + "Schematic paste/save support") + .optional("FastAsyncWorldEdit", + "Faster schematic paste/save support") + .check(this); + + // Hard stop if a required dep is missing + if (dependencyCheckResult.hasFatal()) { + log.severe("Core cannot start — required dependencies are missing."); + log.severe("Install the missing plugins and restart the server."); + getServer().getPluginManager().disablePlugin(this); + return; + } + } + @Override public void onEnable() { instance = this; @@ -166,6 +207,9 @@ public void onEnable() { log.info("\\____/\\____/_/ \\___/ "); log.info("Framework booting... "); + checkDependencies(); + if (!isEnabled()) return; + try { bootFramework(); } catch (CoreException ex) { @@ -202,6 +246,9 @@ public void onDisable() { safeRun("DataStoreManager.flushAll", () -> dataStoreManager.flushAll()); + safeRun("CooldownManager.shutdown", + () -> cooldownManager.shutdown()); + // 5. Save all configs safeRun("ConfigManager.saveAll", () -> configManager.saveAll()); @@ -276,6 +323,8 @@ private void bootFramework() { step("Scanning classpath", this::initScanner); step("Wiring database repositories", () -> databaseManager.discoverAndWire(scanResult)); + step("Running config migrations", + () -> configMigrationManager.migrateAll(getDataFolder())); step("Initializing ConfigManager", this::initConfigs); step("Initializing DataStoreManager", this::initDataStores); step("Registering commands", this::initCommands); @@ -300,6 +349,7 @@ private void initContainer() { // Self-register the plugin and server into the container container.bindInstance(CorePlugin.class, this); + container.bindInstance( org.bukkit.Server.class, getServer() @@ -313,6 +363,15 @@ private void initContainer() { getDataFolder().toPath() ); + validationManager = new ValidationManager(); + container.bindInstance(ValidationManager.class, validationManager); + + configMigrationManager = new ConfigMigrationManager(); + container.bindInstance(ConfigMigrationManager.class, configMigrationManager); + + profilingManager = new ProfilingManager(); + container.bindInstance(ProfilingManager.class, profilingManager); + // Construct and register all framework managers moduleRegistry = new ModuleRegistry(); configManager = new ConfigManager( @@ -324,6 +383,8 @@ private void initContainer() { getDataFolder().toPath(), container ); + cooldownManager = new CooldownManager(this); + container.bindInstance(CooldownManager.class, cooldownManager); commandManager = new CommandManager(getName(), container); inventoryManager = new InventoryManager(container, this); placeholderManager = new PlaceholderManager(this, container); @@ -353,6 +414,8 @@ private void initContainer() { mapDisplayManager = new MapDisplayManager(this); cacheManager = new CacheManager(this); cutsceneManager = new CutsceneManager(this); + retryManager = new RetryManager(); + rateLimitManager = new RateLimitManager(this); container.bindInstance(ModuleRegistry.class, moduleRegistry); container.bindInstance(ConfigManager.class, configManager); @@ -385,6 +448,8 @@ private void initContainer() { container.bindInstance(MapDisplayManager.class, mapDisplayManager); container.bindInstance(CacheManager.class, cacheManager); container.bindInstance(CutsceneManager.class, cutsceneManager); + container.bindInstance(RetryManager.class, retryManager); + container.bindInstance(RateLimitManager.class, rateLimitManager); } @@ -421,6 +486,12 @@ private void initConfigs() { private void initDataStores() { dataStoreManager.initializeAll(scanResult); + + // Cooldown + final PersistentCooldownStore cooldownStore = + container.resolve(PersistentCooldownStore.class); + cooldownManager.setPersistentStore(cooldownStore); + cooldownManager.loadPersisted(); } private void initCommands() { diff --git a/src/main/java/dev/mzcy/core/annotation/Cooldown.java b/src/main/java/dev/mzcy/core/annotation/Cooldown.java index 0e5769f..005e819 100644 --- a/src/main/java/dev/mzcy/core/annotation/Cooldown.java +++ b/src/main/java/dev/mzcy/core/annotation/Cooldown.java @@ -67,4 +67,12 @@ * Always includes {@code core.cooldown.bypass} automatically. */ String bypassPermission() default ""; + + /** + * Whether this cooldown should persist across server restarts. + * Requires a {@link dev.mzcy.core.cooldown.PersistentCooldownStore} + * to be registered. + * Defaults to {@code false} — in-memory only. + */ + boolean persistent() default false; } \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/command/CommandManager.java b/src/main/java/dev/mzcy/core/command/CommandManager.java index 7fc8a38..8e128b4 100644 --- a/src/main/java/dev/mzcy/core/command/CommandManager.java +++ b/src/main/java/dev/mzcy/core/command/CommandManager.java @@ -42,7 +42,7 @@ public final class CommandManager { public CommandManager(@NotNull String pluginName, @NotNull Container container) { this.pluginName = pluginName.toLowerCase(Locale.ROOT); this.container = container; - this.cooldownManager = new CooldownManager(); + this.cooldownManager = container.resolve(CooldownManager.class); container.bindInstance(CooldownManager.class, cooldownManager); this.commandMap = resolveCommandMap(); } diff --git a/src/main/java/dev/mzcy/core/config/migration/ConfigMigration.java b/src/main/java/dev/mzcy/core/config/migration/ConfigMigration.java new file mode 100644 index 0000000..92842f4 --- /dev/null +++ b/src/main/java/dev/mzcy/core/config/migration/ConfigMigration.java @@ -0,0 +1,62 @@ +package dev.mzcy.core.config.migration; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.jetbrains.annotations.NotNull; + +/** + * A single versioned migration step for a config file. + * + *

Receives the raw JSON/YAML data as a Jackson {@link ObjectNode} + * so it can rename, remove, add, or transform fields without needing + * the target config class to be instantiated. + * + *

Example — rename {@code homeLimit} to {@code maxHomes} in v2: + *

{@code
+ * public class V2_RenameHomeLimit implements ConfigMigration {
+ *
+ *     @Override public int getTargetVersion() { return 2; }
+ *
+ *     @Override public String getDescription() {
+ *         return "Rename homeLimit → maxHomes";
+ *     }
+ *
+ *     @Override
+ *     public void migrate(@NotNull ObjectNode node) {
+ *         if (node.has("homeLimit")) {
+ *             node.set("maxHomes", node.get("homeLimit"));
+ *             node.remove("homeLimit");
+ *         }
+ *     }
+ * }
+ * }
+ */ +public interface ConfigMigration { + + /** + * The schema version this migration produces. + * + *

A migration with {@code getTargetVersion() = 3} transforms + * a v2 config into a v3 config. + */ + int getTargetVersion(); + + /** + * Short human-readable description of what this migration does. + * Shown in the console log when the migration runs. + */ + @NotNull + String getDescription(); + + /** + * Applies this migration to the raw config data. + * + *

The node represents the entire config file as a JSON object. + * Mutate it in-place — add, remove, rename or transform fields. + * + *

The {@code _version} field is managed automatically by the + * {@link ConfigMigrationRunner} — do not touch it here. + * + * @param node the raw config data to migrate + */ + void migrate(@NotNull ObjectNode node); +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/config/migration/ConfigMigrationManager.java b/src/main/java/dev/mzcy/core/config/migration/ConfigMigrationManager.java new file mode 100644 index 0000000..7a764a3 --- /dev/null +++ b/src/main/java/dev/mzcy/core/config/migration/ConfigMigrationManager.java @@ -0,0 +1,133 @@ +package dev.mzcy.core.config.migration; + +import dev.mzcy.core.config.AbstractConfig; +import dev.mzcy.core.exception.ConfigException; +import dev.mzcy.core.scanner.ScanResult; +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +import java.nio.file.Path; +import java.util.*; +import java.util.logging.Level; + +/** + * Central manager for config schema migrations. + * + *

Maintains a registry of {@link ConfigMigrationRunner}s per config class, + * and runs all pending migrations before configs are loaded. + * + *

Usage: + *

{@code
+ * // Register migrations for a specific config class
+ * configMigrationManager
+ *     .forConfig(MainConfig.class)
+ *     .register(new V2_RenameHomeLimit())
+ *     .register(new V3_AddDebugSection());
+ *
+ * // Run all pending migrations (call before configManager.initializeAll)
+ * configMigrationManager.migrateAll(dataFolder);
+ * }
+ */ +@Log +public final class ConfigMigrationManager { + + /** Runners per config class. */ + private final Map, ConfigMigrationRunner> + runners = new LinkedHashMap<>(); + + // ========================================================================= + // Registration + // ========================================================================= + + /** + * Returns the {@link ConfigMigrationRunner} for the given config class, + * creating it if it does not exist. + * + *

Chain {@link ConfigMigrationRunner#register} calls on the returned runner. + * + * @param configClass the config class to register migrations for + * @return the runner for this config class + */ + @NotNull + public ConfigMigrationRunner forConfig( + @NotNull Class configClass + ) { + return runners.computeIfAbsent(configClass, k -> new ConfigMigrationRunner()); + } + + // ========================================================================= + // Migration + // ========================================================================= + + /** + * Runs all pending migrations for all registered config classes. + * + *

Must be called before + * {@link dev.mzcy.core.config.ConfigManager#initializeAll} so configs + * are migrated before they are deserialized. + * + * @param dataFolder the plugin data folder (configs live here) + */ + public void migrateAll(@NotNull java.io.File dataFolder) { + if (runners.isEmpty()) return; + + log.info("Running config migrations..."); + int migrated = 0; + + for (final var entry : runners.entrySet()) { + final Class cls = entry.getKey(); + final ConfigMigrationRunner runner = entry.getValue(); + + final ConfigVersion versionAnnotation = + cls.getAnnotation(ConfigVersion.class); + if (versionAnnotation == null) { + log.warning("Config class " + cls.getSimpleName() + + " has a migration runner but no @ConfigVersion annotation — skipping."); + continue; + } + + final dev.mzcy.core.annotation.Config configAnnotation = + cls.getAnnotation(dev.mzcy.core.annotation.Config.class); + if (configAnnotation == null) continue; + + final String directory = configAnnotation.directory(); + final String filename = configAnnotation.value() + + (configAnnotation.format().name().equals("JSON") ? ".json" : ".yml"); + + final Path filePath = directory.isBlank() + ? dataFolder.toPath().resolve(filename) + : dataFolder.toPath().resolve(directory).resolve(filename); + + try { + final int before = runner.readFileVersion(filePath); + runner.migrate(filePath, versionAnnotation.value()); + final int after = runner.readFileVersion(filePath); + if (after > before) migrated++; + } catch (ConfigException ex) { + log.log(Level.SEVERE, + "Failed to migrate config: " + cls.getSimpleName(), ex); + } + } + + if (migrated > 0) { + log.info("Config migrations complete: " + migrated + + " file(s) updated."); + } else { + log.fine("All configs are up to date."); + } + } + + /** + * Returns true if any migration runners are registered. + */ + public boolean hasMigrations() { + return !runners.isEmpty(); + } + + /** + * Returns the number of registered config classes with migrations. + */ + public int registeredCount() { + return runners.size(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/config/migration/ConfigMigrationRunner.java b/src/main/java/dev/mzcy/core/config/migration/ConfigMigrationRunner.java new file mode 100644 index 0000000..9d9c8fe --- /dev/null +++ b/src/main/java/dev/mzcy/core/config/migration/ConfigMigrationRunner.java @@ -0,0 +1,228 @@ +package dev.mzcy.core.config.migration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import dev.mzcy.core.exception.ConfigException; +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.nio.file.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.logging.Level; + +/** + * Applies pending {@link ConfigMigration}s to a config file on disk. + * + *

The runner: + *

    + *
  1. Reads the raw file (YAML or JSON)
  2. + *
  3. Reads the {@code _version} field (defaults to {@code 0} if absent)
  4. + *
  5. Applies all migrations between file version and target version in order
  6. + *
  7. Writes the {@code _version} field after each migration
  8. + *
  9. Saves the migrated file back to disk
  10. + *
+ * + *

Before migrating, the original file is backed up as + * {@code .backup.} so data is never lost. + * + *

Usage: + *

{@code
+ * ConfigMigrationRunner runner = new ConfigMigrationRunner();
+ *
+ * runner.register(new V2_RenameHomeLimit());
+ * runner.register(new V3_AddDebugSection());
+ *
+ * runner.migrate(configFilePath, 3); // target version = @ConfigVersion value
+ * }
+ */ +@Log +public final class ConfigMigrationRunner { + + private static final String VERSION_FIELD = "_version"; + private static final DateTimeFormatter BACKUP_FMT = + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"); + + private final ObjectMapper yamlMapper; + private final ObjectMapper jsonMapper; + + /** All registered migrations, sorted by target version ascending. */ + private final NavigableMap migrations + = new TreeMap<>(); + + public ConfigMigrationRunner() { + this.yamlMapper = new ObjectMapper( + new YAMLFactory() + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)); + this.jsonMapper = new ObjectMapper(); + } + + // ========================================================================= + // Registration + // ========================================================================= + + /** + * Registers a migration step. + * + * @param migration the migration to register + * @throws IllegalArgumentException if a migration for the same version + * is already registered + */ + @NotNull + public ConfigMigrationRunner register(@NotNull ConfigMigration migration) { + final int version = migration.getTargetVersion(); + if (version < 1) throw new IllegalArgumentException( + "Migration target version must be ≥ 1, got: " + version); + if (migrations.containsKey(version)) throw new IllegalArgumentException( + "Duplicate migration for version " + version); + migrations.put(version, migration); + return this; + } + + // ========================================================================= + // Migration + // ========================================================================= + + /** + * Migrates a config file to the target version if needed. + * + *

No-op if the file is already at or above the target version. + * + * @param filePath path to the config file + * @param targetVersion the version declared in {@link ConfigVersion} + * @throws ConfigException if reading, migrating, or writing fails + */ + public void migrate( + @NotNull Path filePath, + int targetVersion + ) { + if (!Files.exists(filePath)) return; // no file yet — nothing to migrate + + final boolean isYaml = filePath.toString().endsWith(".yml") + || filePath.toString().endsWith(".yaml"); + final ObjectMapper mapper = isYaml ? yamlMapper : jsonMapper; + + // Read raw data + final ObjectNode root; + try { + root = (ObjectNode) mapper.readTree(filePath.toFile()); + } catch (IOException ex) { + throw new ConfigException( + "Failed to read config for migration: " + filePath, ex); + } + + // Read current file version + final int fileVersion = root.has(VERSION_FIELD) + ? root.get(VERSION_FIELD).asInt(0) : 0; + + if (fileVersion >= targetVersion) { + log.fine(() -> "Config [" + filePath.getFileName() + + "] is at version " + fileVersion + " — no migration needed."); + return; + } + + // Find pending migrations + final List pending = migrations.subMap( + fileVersion + 1, true, + targetVersion, true + ).values().stream().toList(); + + if (pending.isEmpty()) { + // No migrations registered for the gap — update version field only + log.warning("No migrations registered between v" + fileVersion + + " and v" + targetVersion + + " for [" + filePath.getFileName() + "]. " + + "Updating version field only."); + root.put(VERSION_FIELD, targetVersion); + writeBack(mapper, root, filePath); + return; + } + + // Backup before any changes + backup(filePath); + + log.info("Migrating [" + filePath.getFileName() + + "] from v" + fileVersion + " → v" + targetVersion + + " (" + pending.size() + " step(s))"); + + // Apply each migration in order + int currentVersion = fileVersion; + for (final ConfigMigration migration : pending) { + try { + log.info(" Applying V" + migration.getTargetVersion() + + ": " + migration.getDescription()); + migration.migrate(root); + root.put(VERSION_FIELD, migration.getTargetVersion()); + currentVersion = migration.getTargetVersion(); + } catch (Exception ex) { + throw new ConfigException( + "Migration V" + migration.getTargetVersion() + + " failed for [" + filePath.getFileName() + "]. " + + "Backup saved. Original data intact.", ex); + } + } + + // Write migrated data back + writeBack(mapper, root, filePath); + log.info("Migration complete: [" + + filePath.getFileName() + "] now at v" + currentVersion); + } + + /** + * Returns the version stored in a config file, or {@code 0} if absent. + * + * @param filePath path to the config file + * @return the stored version + */ + public int readFileVersion(@NotNull Path filePath) { + if (!Files.exists(filePath)) return 0; + try { + final boolean isYaml = filePath.toString().endsWith(".yml") + || filePath.toString().endsWith(".yaml"); + final JsonNode root = (isYaml ? yamlMapper : jsonMapper) + .readTree(filePath.toFile()); + return root.has(VERSION_FIELD) + ? root.get(VERSION_FIELD).asInt(0) : 0; + } catch (IOException ex) { + log.log(Level.WARNING, + "Could not read version from: " + filePath, ex); + return 0; + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private void backup(@NotNull Path filePath) { + try { + final String timestamp = LocalDateTime.now().format(BACKUP_FMT); + final Path backup = filePath.resolveSibling( + filePath.getFileName() + ".backup." + timestamp); + Files.copy(filePath, backup, StandardCopyOption.REPLACE_EXISTING); + log.info("Backup created: " + backup.getFileName()); + } catch (IOException ex) { + log.log(Level.WARNING, + "Failed to create backup for: " + filePath, ex); + } + } + + private void writeBack( + @NotNull ObjectMapper mapper, + @NotNull ObjectNode root, + @NotNull Path filePath + ) { + try { + mapper.writerWithDefaultPrettyPrinter() + .writeValue(filePath.toFile(), root); + } catch (IOException ex) { + throw new ConfigException( + "Failed to write migrated config: " + filePath, ex); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/config/migration/ConfigVersion.java b/src/main/java/dev/mzcy/core/config/migration/ConfigVersion.java new file mode 100644 index 0000000..c0f193e --- /dev/null +++ b/src/main/java/dev/mzcy/core/config/migration/ConfigVersion.java @@ -0,0 +1,42 @@ +package dev.mzcy.core.config.migration; + +import java.lang.annotation.*; + +/** + * Declares the current schema version of an + * {@link dev.mzcy.core.config.AbstractConfig} class. + * + *

When the {@link ConfigMigrationRunner} loads a config file, + * it compares the {@code _version} field stored on disk against + * the version declared here. If the file is older, all migrations + * between the two versions are applied in order. + * + *

Example: + *

{@code
+ * @Config("settings")
+ * @ConfigVersion(2)
+ * public class MainConfig extends AbstractConfig {
+ *     public String prefix = "[Server]";
+ *     public int    maxHomes = 5;
+ *     // renamed from "homeLimit" in v1 → "maxHomes" in v2
+ * }
+ * }
+ * + *

Rules: + *

+ */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ConfigVersion { + + /** + * The current schema version of this config class. + * Must be ≥ 1. + */ + int value(); +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/cooldown/CooldownEntry.java b/src/main/java/dev/mzcy/core/cooldown/CooldownEntry.java index cb83d88..2a6943e 100644 --- a/src/main/java/dev/mzcy/core/cooldown/CooldownEntry.java +++ b/src/main/java/dev/mzcy/core/cooldown/CooldownEntry.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; +import java.io.Serializable; import java.time.Instant; /** @@ -11,7 +12,7 @@ */ @Getter @RequiredArgsConstructor -public final class CooldownEntry { +public final class CooldownEntry implements Serializable { /** * The instant this cooldown was applied. diff --git a/src/main/java/dev/mzcy/core/cooldown/CooldownKey.java b/src/main/java/dev/mzcy/core/cooldown/CooldownKey.java index 8bf268d..54cdffc 100644 --- a/src/main/java/dev/mzcy/core/cooldown/CooldownKey.java +++ b/src/main/java/dev/mzcy/core/cooldown/CooldownKey.java @@ -16,7 +16,7 @@ public final class CooldownKey { /** * Sentinel UUID used for global cooldowns. */ - private static final UUID GLOBAL_UUID = new UUID(0, 0); + public static final UUID GLOBAL_UUID = new UUID(0, 0); private final UUID senderUuid; private final String commandKey; diff --git a/src/main/java/dev/mzcy/core/cooldown/CooldownManager.java b/src/main/java/dev/mzcy/core/cooldown/CooldownManager.java index 7a758df..0ed5c07 100644 --- a/src/main/java/dev/mzcy/core/cooldown/CooldownManager.java +++ b/src/main/java/dev/mzcy/core/cooldown/CooldownManager.java @@ -1,19 +1,17 @@ package dev.mzcy.core.cooldown; import dev.mzcy.core.annotation.Cooldown; -import dev.mzcy.core.util.TimeUtil; import lombok.Setter; import lombok.extern.java.Log; -import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.time.Duration; import java.time.Instant; -import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -22,252 +20,291 @@ import java.util.concurrent.TimeUnit; /** - * Manages all active cooldowns for commands and sub-commands. + * Manages command cooldowns with optional persistence across restarts. * - *

Uses an in-memory {@link ConcurrentHashMap} — cooldowns are intentionally - * not persisted across restarts (this is the standard expectation). + *

By default, cooldowns are in-memory only — they reset on server restart. + * Inject a {@link PersistentCooldownStore} via {@link #setPersistentStore} + * to make them survive restarts. * - *

A background thread evicts expired entries every 60 seconds - * to prevent unbounded memory growth on busy servers. - * - *

Integration with the command framework happens in {@link dev.mzcy.core.command.BaseCommand} - * via {@link #checkAndApply(CommandSender, String, Cooldown)}. + *

Persistence is opt-in per cooldown via + * {@link Cooldown#persistent()} — only cooldowns with {@code persistent = true} + * are written to disk. */ @Log public final class CooldownManager { - private static final String BYPASS_PERMISSION = "core.cooldown.bypass"; private static final MiniMessage MINI = MiniMessage.miniMessage(); - private static final String DEFAULT_MESSAGE = - "You must wait before using this again."; + private final Plugin plugin; - /** - * All active cooldown entries. - */ - private final Map entries = new ConcurrentHashMap<>(); + /** In-memory cooldown entries. Key = CooldownKey string. */ + private final ConcurrentHashMap entries + = new ConcurrentHashMap<>(); /** - * Background eviction scheduler. + * Optional persistent store — null until + * {@link #setPersistentStore} is called. */ - private final ScheduledExecutorService evictionScheduler = + @Setter + @Nullable + private PersistentCooldownStore persistentStore; + + private final ScheduledExecutorService evictionExecutor = Executors.newSingleThreadScheduledExecutor(r -> { final Thread t = new Thread(r, "core-cooldown-eviction"); t.setDaemon(true); return t; }); - /** - * Overrideable default message. - */ - @Setter - @NotNull - private String defaultMessage = DEFAULT_MESSAGE; - - public CooldownManager() { - evictionScheduler.scheduleAtFixedRate( - this::evictExpired, 60, 60, TimeUnit.SECONDS - ); + public CooldownManager(@NotNull Plugin plugin) { + this.plugin = plugin; + // Evict expired in-memory entries every 60 seconds + evictionExecutor.scheduleAtFixedRate( + this::evictExpired, 60, 60, TimeUnit.SECONDS); } // ========================================================================= - // Core API + // Core API — unchanged // ========================================================================= /** - * Checks whether a sender is on cooldown for a given command key, - * and if not, applies the cooldown immediately. + * Checks if the sender is on cooldown and applies the cooldown if not. + * Sends the denial message automatically. * - *

This is the primary method called by the command framework. - * - * @param sender the command sender - * @param commandKey unique key identifying the command/sub-command - * @param cooldown the {@link Cooldown} annotation metadata - * @return {@code true} if the sender is NOT on cooldown (execution should proceed), - * {@code false} if they ARE on cooldown (execution should be blocked) + * @param sender the command sender + * @param key the cooldown key (e.g., {@code "cmd:heal"}) + * @param cooldown the cooldown annotation + * @return true if the action is allowed, false if blocked */ public boolean checkAndApply( @NotNull CommandSender sender, - @NotNull String commandKey, + @NotNull String key, @NotNull Cooldown cooldown ) { - // Bypass check - if (hasBypass(sender, cooldown)) return true; + if (sender.hasPermission("core.cooldown.bypass")) return true; + + final UUID uuid = cooldown.global() + ? CooldownKey.GLOBAL_UUID + : (sender instanceof Player p ? p.getUniqueId() : CooldownKey.GLOBAL_UUID); + + final String entryKey = CooldownKey.of(uuid, key).toString(); + final CooldownEntry existing = resolveEntry(entryKey); + + if (existing != null && !existing.isExpired()) { + // Build denial message + final String raw = cooldown.message().isBlank() + ? "Please wait before using this again." + : cooldown.message(); + + final long remainingMs = existing.remainingMillis(); + final long totalMs = existing.totalMillis(); - final CooldownKey key = buildKey(sender, commandKey, cooldown); - final Optional existing = getEntry(key); + final String msg = raw + .replace("", formatDuration(remainingMs)) + .replace("", formatDuration(totalMs)); - if (existing.isPresent() && !existing.get().isExpired()) { - // Still on cooldown — send message and block - sendCooldownMessage(sender, existing.get(), cooldown); + sender.sendMessage(MINI.deserialize(msg)); return false; } - // Not on cooldown — apply and allow - apply(key, cooldown); + // Apply cooldown + final long durationMs = cooldown.unit().toMillis(cooldown.value()); + final CooldownEntry entry = new CooldownEntry( + Instant.now(), Instant.now().plusMillis(durationMs)); + + entries.put(entryKey, entry); + + // Persist if annotation requests it + if (cooldown.persistent() && persistentStore != null) { + persistentStore.put(entryKey, entry); + } + return true; } /** - * Manually applies a cooldown for a sender + command key. + * Manually applies a cooldown for a player and key. * - * @param sender the command sender - * @param commandKey unique command identifier + * @param player the target player + * @param key the cooldown key * @param duration the cooldown duration + * @param persistent whether to persist across restarts */ public void apply( - @NotNull CommandSender sender, - @NotNull String commandKey, - @NotNull Duration duration + @NotNull Player player, + @NotNull String key, + @NotNull Duration duration, + boolean persistent ) { - final UUID uuid = sender instanceof Player p - ? p.getUniqueId() - : new UUID(0, 1); // Console sentinel - final CooldownKey key = CooldownKey.of(uuid, commandKey); - apply(key, duration); - } + final String entryKey = CooldownKey.of(player.getUniqueId(), key).toString(); + final CooldownEntry entry = new CooldownEntry( + Instant.now(), Instant.now().plus(duration)); - /** - * Clears any active cooldown for the given sender + command key. - * - * @param sender the command sender - * @param commandKey unique command identifier - */ - public void clear( - @NotNull CommandSender sender, - @NotNull String commandKey - ) { - final UUID uuid = sender instanceof Player p - ? p.getUniqueId() - : new UUID(0, 1); - entries.remove(CooldownKey.of(uuid, commandKey)); + entries.put(entryKey, entry); + + if (persistent && persistentStore != null) { + persistentStore.put(entryKey, entry); + } } - /** - * Clears all active cooldowns for a given sender. - * - * @param sender the command sender - */ - public void clearAll(@NotNull CommandSender sender) { - if (!(sender instanceof Player player)) return; - final UUID uuid = player.getUniqueId(); - entries.keySet().removeIf(key -> key.toString().startsWith(uuid.toString())); + /** Overload for in-memory only. */ + public void apply( + @NotNull Player player, + @NotNull String key, + @NotNull Duration duration + ) { + apply(player, key, duration, false); } /** - * Returns the remaining cooldown duration for a sender + command key. + * Returns the remaining cooldown duration for a player and key. * - * @return remaining {@link Duration}, or {@link Duration#ZERO} if not on cooldown + * @param player the player + * @param key the cooldown key + * @return the remaining duration, or {@link Duration#ZERO} if not on cooldown */ @NotNull public Duration getRemaining( - @NotNull CommandSender sender, - @NotNull String commandKey + @NotNull Player player, + @NotNull String key ) { - final UUID uuid = sender instanceof Player p ? p.getUniqueId() : new UUID(0, 1); - final CooldownEntry entry = entries.get(CooldownKey.of(uuid, commandKey)); + final String entryKey = CooldownKey.of(player.getUniqueId(), key).toString(); + final CooldownEntry entry = resolveEntry(entryKey); if (entry == null || entry.isExpired()) return Duration.ZERO; return Duration.ofMillis(entry.remainingMillis()); } /** - * Returns true if the sender is currently on cooldown for the given key. + * Returns true if the player is currently on cooldown for the given key. */ public boolean isOnCooldown( - @NotNull CommandSender sender, - @NotNull String commandKey + @NotNull Player player, + @NotNull String key ) { - return !getRemaining(sender, commandKey).isZero(); + return getRemaining(player, key).toMillis() > 0; } /** - * Returns the number of active (non-expired) cooldown entries. + * Clears the cooldown for a player and key. + * + * @param player the player + * @param key the cooldown key */ - public int activeCount() { - return (int) entries.values().stream() - .filter(e -> !e.isExpired()) - .count(); + public void clear( + @NotNull Player player, + @NotNull String key + ) { + final String entryKey = CooldownKey.of(player.getUniqueId(), key).toString(); + entries.remove(entryKey); + if (persistentStore != null) { + persistentStore.remove(entryKey); + } } /** - * Shuts down the background eviction thread. Call on plugin disable. + * Clears ALL cooldowns for a player. + * + * @param player the player */ - public void shutdown() { - evictionScheduler.shutdownNow(); - entries.clear(); + public void clearAll(@NotNull Player player) { + final String prefix = player.getUniqueId().toString(); + entries.keySet().removeIf(k -> k.startsWith(prefix)); + if (persistentStore != null) { + persistentStore.getAll().keySet().stream() + .filter(k -> k.startsWith(prefix)) + .forEach(persistentStore::remove); + } } // ========================================================================= - // Internals + // Persistence — load on startup // ========================================================================= - private void apply(@NotNull CooldownKey key, @NotNull Cooldown cooldown) { - final long millis = cooldown.unit().toMillis(cooldown.value()); - apply(key, Duration.ofMillis(millis)); - } - - private void apply(@NotNull CooldownKey key, @NotNull Duration duration) { - final Instant now = Instant.now(); - entries.put(key, new CooldownEntry(now, now.plus(duration))); - } + /** + * Loads all persisted cooldowns into the in-memory cache. + * + *

Called once after the {@link PersistentCooldownStore} is initialized. + * Expired entries are discarded immediately — no point loading them. + */ + public void loadPersisted() { + if (persistentStore == null) return; - @NotNull - private Optional getEntry(@NotNull CooldownKey key) { - return Optional.ofNullable(entries.get(key)); - } + int loaded = 0; + int expired = 0; - @NotNull - private CooldownKey buildKey( - @NotNull CommandSender sender, - @NotNull String commandKey, - @NotNull Cooldown cooldown - ) { - if (cooldown.global()) { - return CooldownKey.global(commandKey); + for (final var entry : persistentStore.getAll().entrySet()) { + final CooldownEntry cooldown = entry.getValue(); + if (cooldown.isExpired()) { + persistentStore.remove(entry.getKey()); + expired++; + } else { + entries.put(entry.getKey(), cooldown); + loaded++; + } } - final UUID uuid = sender instanceof Player p - ? p.getUniqueId() - : new UUID(0, 1); - return CooldownKey.of(uuid, commandKey); + + log.info("Loaded " + loaded + " persistent cooldown(s). " + + "Discarded " + expired + " expired."); } - private boolean hasBypass(@NotNull CommandSender sender, @NotNull Cooldown cooldown) { - if (sender.hasPermission(BYPASS_PERMISSION)) return true; - if (!cooldown.bypassPermission().isBlank() - && sender.hasPermission(cooldown.bypassPermission())) return true; - return false; + // ========================================================================= + // Shutdown + // ========================================================================= + + /** + * Stops the eviction thread. Call on plugin disable. + */ + public void shutdown() { + evictionExecutor.shutdownNow(); } - private void sendCooldownMessage( - @NotNull CommandSender sender, - @NotNull CooldownEntry entry, - @NotNull Cooldown cooldown - ) { - final String template = cooldown.message().isBlank() - ? defaultMessage - : cooldown.message(); - - final String remaining = TimeUtil.formatSeconds( - entry.remainingMillis() / 1000 - ); - final String total = TimeUtil.formatSeconds( - entry.totalMillis() / 1000 - ); - - final Component message = MINI.deserialize( - template, - Placeholder.parsed("remaining", remaining), - Placeholder.parsed("total", total) - ); - sender.sendMessage(message); + // ========================================================================= + // Internal + // ========================================================================= + + /** + * Resolves an entry — checks in-memory first, then persistent store. + */ + @Nullable + private CooldownEntry resolveEntry(@NotNull String key) { + final CooldownEntry memory = entries.get(key); + if (memory != null) return memory; + + // Fallback to persistent store (e.g., after restart before loadPersisted) + if (persistentStore != null) { + final Optional stored = persistentStore.get(key); + if (stored.isPresent()) { + entries.put(key, stored.get()); // warm in-memory cache + return stored.get(); + } + } + return null; } private void evictExpired() { - final int before = entries.size(); - entries.entrySet().removeIf(e -> e.getValue().isExpired()); - final int evicted = before - entries.size(); - if (evicted > 0) { + int count = 0; + for (final var it = entries.entrySet().iterator(); it.hasNext(); ) { + final var entry = it.next(); + if (entry.getValue().isExpired()) { + it.remove(); + // Also remove from persistent store if present + if (persistentStore != null) { + persistentStore.remove(entry.getKey()); + } + count++; + } + } + if (count > 0) { + final int evicted = count; log.fine(() -> "Evicted " + evicted + " expired cooldown(s)."); } } + + @NotNull + private String formatDuration(long millis) { + final long seconds = millis / 1000; + if (seconds < 60) return seconds + "s"; + if (seconds < 3600) return (seconds / 60) + "m " + (seconds % 60) + "s"; + return (seconds / 3600) + "h " + ((seconds % 3600) / 60) + "m"; + } } \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/cooldown/PersistentCooldownStore.java b/src/main/java/dev/mzcy/core/cooldown/PersistentCooldownStore.java new file mode 100644 index 0000000..6aa2842 --- /dev/null +++ b/src/main/java/dev/mzcy/core/cooldown/PersistentCooldownStore.java @@ -0,0 +1,38 @@ +package dev.mzcy.core.cooldown; + +import dev.mzcy.core.annotation.DataStore; +import dev.mzcy.core.data.AbstractDataStore; +import dev.mzcy.core.data.BinaryDataSerializer; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +/** + * Persistent storage for {@link CooldownEntry}s that survive server restarts. + * + *

Stored in {@code plugins/Core/data/cooldowns/_.dat}. + * + *

Key format: {@code "_"} + * e.g. {@code "a1b2c3d4-..._cmd:heal"} + * + *

Auto-discovered and initialized by {@link dev.mzcy.core.data.DataStoreManager}. + */ +@DataStore(value = "cooldowns", directory = "data") +public final class PersistentCooldownStore + extends AbstractDataStore { + + public PersistentCooldownStore() { + super(new BinaryDataSerializer<>()); + } + + @Override + protected String keyToFileName(@NotNull String key) { + // Replace colon — not valid in filenames on Windows + return key.replace(":", "__"); + } + + @Override + protected String fileNameToKey(@NotNull String fileName) { + return fileName.replace("__", ":"); + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/debug/DebugCommand.java b/src/main/java/dev/mzcy/core/debug/DebugCommand.java index 4249543..b6cf600 100644 --- a/src/main/java/dev/mzcy/core/debug/DebugCommand.java +++ b/src/main/java/dev/mzcy/core/debug/DebugCommand.java @@ -6,9 +6,12 @@ import dev.mzcy.core.command.CommandContext; import dev.mzcy.core.di.Container; import dev.mzcy.core.di.Scope; +import dev.mzcy.core.profiling.TimingSummary; import lombok.extern.java.Log; import org.jetbrains.annotations.NotNull; +import java.util.List; + /** * The {@code /core} command — entry point to the Core framework CLI. * @@ -92,6 +95,39 @@ public void onBindings(@NotNull CommandContext ctx) { }); } + @SubCommand(value = "timings", permission = "core.admin") + public void onTimings(CommandContext ctx) { + final List all = core.getProfilingManager() + .getRegistry().getAll(); + + if (all.isEmpty()) { + ctx.send("No timing data recorded yet."); + return; + } + + ctx.send("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + ctx.send("Profiling — All Methods"); + ctx.send("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + + for (final TimingSummary s : all) { + ctx.send("" + s.getKey()); + ctx.send(" avg=" + String.format("%.2f", s.avgMs()) + + "ms min=" + String.format("%.2f", s.minMs()) + + "ms max=" + String.format("%.2f", s.maxMs()) + + "ms calls=" + s.getInvocationCount()); + } + + ctx.send("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + + // Reset option + if (ctx.arg(0).map("reset"::equalsIgnoreCase).orElse(false)) { + core.getProfilingManager().resetAll(); + ctx.sendSuccess("Timing stats reset."); + } else { + ctx.send("Tip: /core timings reset"); + } + } + // ========================================================================= // /core gc // ========================================================================= diff --git a/src/main/java/dev/mzcy/core/debug/DebugOverlay.java b/src/main/java/dev/mzcy/core/debug/DebugOverlay.java index fc9e92b..92eadd8 100644 --- a/src/main/java/dev/mzcy/core/debug/DebugOverlay.java +++ b/src/main/java/dev/mzcy/core/debug/DebugOverlay.java @@ -1,6 +1,7 @@ package dev.mzcy.core.debug; import dev.mzcy.core.di.Container; +import dev.mzcy.core.profiling.TimingSummary; import dev.mzcy.core.scanner.ScanResult; import lombok.Getter; import lombok.extern.java.Log; @@ -12,6 +13,7 @@ import java.lang.management.ManagementFactory; import java.lang.management.MemoryMXBean; import java.lang.management.RuntimeMXBean; +import java.util.List; /** * The Core debug overlay — accessible via {@code /core debug}. @@ -153,6 +155,9 @@ private void registerBuiltins() { registerNpcSection(); registerPapiSection(); registerCacheSection(); + registerProfilingSection(); + registry.registerEntry("Rate Limiter", "Active Buckets", () -> + "" + core.getRateLimitManager().getRegistry().size()); } private void registerJvmSection() { @@ -346,6 +351,29 @@ private void registerCacheSection() { }); } + private void registerProfilingSection() { + // Top 5 slowest + registry.registerEntry("Profiling", "Tracked Methods", () -> + "" + core.getProfilingManager().getRegistry().size()); + + registry.registerEntry("Profiling", "Top 5 Slowest", () -> { + final List slowest = + core.getProfilingManager().getSlowest(); + if (slowest.isEmpty()) return "no data yet"; + + final StringBuilder sb = new StringBuilder(); + for (final TimingSummary s : slowest) { + if (!sb.isEmpty()) sb.append("\n"); + sb.append("").append(s.getKey()) + .append("\n ") + .append("avg=").append(String.format("%.2f", s.avgMs())).append("ms") + .append(" max=").append(String.format("%.2f", s.maxMs())).append("ms") + .append(" calls=").append(s.getInvocationCount()); + } + return sb.toString(); + }); + } + // ========================================================================= // Helpers // ========================================================================= diff --git a/src/main/java/dev/mzcy/core/dependency/DependencyCheckResult.java b/src/main/java/dev/mzcy/core/dependency/DependencyCheckResult.java new file mode 100644 index 0000000..0a60eef --- /dev/null +++ b/src/main/java/dev/mzcy/core/dependency/DependencyCheckResult.java @@ -0,0 +1,56 @@ +package dev.mzcy.core.dependency; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * The result of checking a single {@link DependencyEntry}. + */ +@Getter +@RequiredArgsConstructor +public final class DependencyCheckResult { + + public enum Status { + /** Plugin is present and meets the version requirement. */ + PRESENT, + /** Plugin is present but below the minimum version. */ + VERSION_MISMATCH, + /** Plugin is not installed. */ + MISSING + } + + @NotNull private final DependencyEntry entry; + @NotNull private final Status status; + + /** + * The installed plugin instance — null if {@link Status#MISSING}. + */ + @Nullable + private final Plugin installedPlugin; + + /** + * The installed version string — null if {@link Status#MISSING}. + */ + @Nullable + private final String installedVersion; + + // ========================================================================= + // Convenience + // ========================================================================= + + public boolean isPresent() { return status == Status.PRESENT; } + public boolean isMissing() { return status == Status.MISSING; } + public boolean isVersionMismatch(){ return status == Status.VERSION_MISMATCH; } + public boolean isOk() { return status == Status.PRESENT; } + + /** + * Returns true if this result should cause the plugin to disable. + * Only {@link DependencyPriority#REQUIRED} + missing/version-mismatch. + */ + public boolean isFatal() { + return entry.getPriority() == DependencyPriority.REQUIRED && !isPresent(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/dependency/DependencyCheckResultSet.java b/src/main/java/dev/mzcy/core/dependency/DependencyCheckResultSet.java new file mode 100644 index 0000000..9df538c --- /dev/null +++ b/src/main/java/dev/mzcy/core/dependency/DependencyCheckResultSet.java @@ -0,0 +1,161 @@ +package dev.mzcy.core.dependency; + +import lombok.Getter; +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.logging.Level; +import java.util.stream.Collectors; + +/** + * The aggregated results of a {@link DependencyChecker#check} run. + * + *

Provides filtered views and summary logging. + */ +@Log +@Getter +public final class DependencyCheckResultSet { + + @NotNull private final String pluginName; + @NotNull private final List results; + + DependencyCheckResultSet( + @NotNull String pluginName, + @NotNull List results + ) { + this.pluginName = pluginName; + this.results = Collections.unmodifiableList(results); + } + + // ========================================================================= + // Filtered views + // ========================================================================= + + /** + * Returns all results where the dependency is missing or version-mismatched. + */ + @NotNull + public List getMissing() { + return results.stream() + .filter(r -> !r.isPresent()) + .collect(Collectors.toUnmodifiableList()); + } + + /** + * Returns all results where the dependency is present. + */ + @NotNull + public List getPresent() { + return results.stream() + .filter(DependencyCheckResult::isPresent) + .collect(Collectors.toUnmodifiableList()); + } + + /** + * Returns all fatal results — required dependencies that are missing. + * If non-empty, the plugin should disable itself. + */ + @NotNull + public List getFatal() { + return results.stream() + .filter(DependencyCheckResult::isFatal) + .collect(Collectors.toUnmodifiableList()); + } + + /** + * Returns true if any required dependency is missing or version-mismatched. + */ + public boolean hasFatal() { + return results.stream().anyMatch(DependencyCheckResult::isFatal); + } + + /** + * Returns true if all dependencies are present and version-compatible. + */ + public boolean allPresent() { + return results.stream().allMatch(DependencyCheckResult::isPresent); + } + + /** + * Returns the result for a specific plugin name, if checked. + */ + @NotNull + public Optional get(@NotNull String pluginName) { + return results.stream() + .filter(r -> r.getEntry().getPluginName().equals(pluginName)) + .findFirst(); + } + + /** + * Returns true if the named plugin is present and version-compatible. + */ + public boolean isPresent(@NotNull String pluginName) { + return get(pluginName).map(DependencyCheckResult::isPresent).orElse(false); + } + + // ========================================================================= + // Logging + // ========================================================================= + + /** + * Logs the full result set to the server console. + * Called automatically by {@link DependencyChecker#check}. + */ + void log() { + final long present = results.stream().filter(DependencyCheckResult::isPresent).count(); + final long missing = results.stream().filter(r -> !r.isPresent()).count(); + + if (missing == 0) { + log.info("[" + pluginName + "] All " + present + + " dependency/dependencies satisfied."); + return; + } + + log.info("[" + pluginName + "] Dependency check: " + + present + " present, " + missing + " missing."); + + for (final DependencyCheckResult result : results) { + logResult(result); + } + } + + private void logResult(@NotNull DependencyCheckResult result) { + final DependencyEntry entry = result.getEntry(); + final String name = entry.getPluginName(); + final String desc = entry.getDescription(); + + switch (result.getStatus()) { + + case PRESENT -> log.fine( + "[" + pluginName + "] ✔ " + name + + " " + result.getInstalledVersion() + + " — " + desc); + + case MISSING -> { + final Level level = switch (entry.getPriority()) { + case REQUIRED -> Level.SEVERE; + case RECOMMENDED -> Level.WARNING; + case OPTIONAL -> Level.INFO; + }; + final String prefix = switch (entry.getPriority()) { + case REQUIRED -> "✘ [REQUIRED]"; + case RECOMMENDED -> "⚠ [RECOMMENDED]"; + case OPTIONAL -> "○ [OPTIONAL]"; + }; + log.log(level, "[" + pluginName + "] " + + prefix + " " + name + " is not installed." + + " — " + desc); + } + + case VERSION_MISMATCH -> { + final Level level = entry.getPriority() == DependencyPriority.REQUIRED + ? Level.SEVERE : Level.WARNING; + log.log(level, "[" + pluginName + "] ⚠ " + name + + " is installed (" + result.getInstalledVersion() + ")" + + " but minimum " + entry.getMinimumVersion() + " is required." + + " — " + desc); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/dependency/DependencyChecker.java b/src/main/java/dev/mzcy/core/dependency/DependencyChecker.java new file mode 100644 index 0000000..04bee49 --- /dev/null +++ b/src/main/java/dev/mzcy/core/dependency/DependencyChecker.java @@ -0,0 +1,185 @@ +package dev.mzcy.core.dependency; + +import dev.mzcy.core.updater.VersionComparator; +import lombok.extern.java.Log; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Checks whether required, recommended, and optional plugin dependencies + * are present on the server, with optional minimum-version enforcement. + * + *

Integrated into {@link dev.mzcy.core.CorePlugin} to run during boot, + * but can also be used standalone by any plugin: + * + *

{@code
+ * DependencyChecker checker = new DependencyChecker(getServer().getPluginManager());
+ *
+ * checker
+ *     .require("ProtocolLib",   "Required for packet-level operations")
+ *     .recommend("LuckPerms",   "Permission group support")
+ *     .recommend("Vault",       "Economy and permissions API")
+ *     .optional("PlaceholderAPI", "Placeholder support in messages")
+ *     .optional("WorldEdit",    "Schematic paste support")
+ *     .check(this);
+ * }
+ * + *

If any {@link DependencyPriority#REQUIRED} dependency is missing, + * {@link #check} returns a result set containing fatal entries and the + * caller is responsible for disabling the plugin. + */ +@Log +public final class DependencyChecker { + + private final PluginManager pluginManager; + private final List entries = new ArrayList<>(); + + public DependencyChecker(@NotNull PluginManager pluginManager) { + this.pluginManager = pluginManager; + } + + // ========================================================================= + // Builder-style registration + // ========================================================================= + + /** + * Adds a required dependency. + * The plugin will disable if this is missing. + */ + @NotNull + public DependencyChecker require( + @NotNull String pluginName, + @NotNull String description + ) { + entries.add(DependencyEntry.required(pluginName, description)); + return this; + } + + /** + * Adds a recommended dependency with optional minimum version. + */ + @NotNull + public DependencyChecker recommend( + @NotNull String pluginName, + @NotNull String description + ) { + entries.add(DependencyEntry.recommended(pluginName, description)); + return this; + } + + /** + * Adds a recommended dependency with a minimum version requirement. + */ + @NotNull + public DependencyChecker recommend( + @NotNull String pluginName, + @NotNull String minimumVersion, + @NotNull String description + ) { + entries.add(DependencyEntry.recommended( + pluginName, minimumVersion, description)); + return this; + } + + /** + * Adds an optional dependency. + */ + @NotNull + public DependencyChecker optional( + @NotNull String pluginName, + @NotNull String description + ) { + entries.add(DependencyEntry.optional(pluginName, description)); + return this; + } + + /** + * Adds a pre-built {@link DependencyEntry} directly. + */ + @NotNull + public DependencyChecker add(@NotNull DependencyEntry entry) { + entries.add(entry); + return this; + } + + // ========================================================================= + // Checking + // ========================================================================= + + /** + * Runs all dependency checks, logs results, and returns the full + * result set. + * + *

Logging behavior per priority: + *

+ * + *

Call {@link DependencyCheckResultSet#hasFatal()} on the return value + * to determine if the plugin should disable itself. + * + * @param caller the plugin performing the check (used for log prefix) + * @return the full result set + */ + @NotNull + public DependencyCheckResultSet check(@NotNull Plugin caller) { + final List results = new ArrayList<>(); + + for (final DependencyEntry entry : entries) { + results.add(checkEntry(entry)); + } + + final DependencyCheckResultSet set = + new DependencyCheckResultSet(caller.getName(), results); + set.log(); + return set; + } + + // ========================================================================= + // Internal + // ========================================================================= + + @NotNull + private DependencyCheckResult checkEntry(@NotNull DependencyEntry entry) { + final Plugin plugin = pluginManager.getPlugin(entry.getPluginName()); + + // Not installed + if (plugin == null || !plugin.isEnabled()) { + return new DependencyCheckResult( + entry, + DependencyCheckResult.Status.MISSING, + null, null + ); + } + + final String installedVersion = + plugin.getPluginMeta().getVersion(); + + // Version check + if (entry.getMinimumVersion() != null) { + final boolean meetsVersion = !VersionComparator.isNewer( + entry.getMinimumVersion(), installedVersion); + + if (!meetsVersion) { + return new DependencyCheckResult( + entry, + DependencyCheckResult.Status.VERSION_MISMATCH, + plugin, installedVersion + ); + } + } + + return new DependencyCheckResult( + entry, + DependencyCheckResult.Status.PRESENT, + plugin, installedVersion + ); + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/dependency/DependencyEntry.java b/src/main/java/dev/mzcy/core/dependency/DependencyEntry.java new file mode 100644 index 0000000..bc35e35 --- /dev/null +++ b/src/main/java/dev/mzcy/core/dependency/DependencyEntry.java @@ -0,0 +1,93 @@ +package dev.mzcy.core.dependency; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A single dependency declaration checked by the {@link DependencyChecker}. + * + *

Created via {@link DependencyEntry#required}, + * {@link DependencyEntry#recommended}, or {@link DependencyEntry#optional}. + */ +@Getter +@RequiredArgsConstructor +public final class DependencyEntry { + + /** The exact plugin name as registered on the server. */ + @NotNull + private final String pluginName; + + /** How critical this dependency is. */ + @NotNull + private final DependencyPriority priority; + + /** + * Optional minimum version string (SemVer). + * {@code null} = any version is acceptable. + */ + @Nullable + private final String minimumVersion; + + /** + * Human-readable description of what this dependency provides. + * Shown in the warning message when the dependency is missing. + */ + @NotNull + private final String description; + + // ========================================================================= + // Factories + // ========================================================================= + + /** + * A required dependency — plugin disables if missing. + */ + @NotNull + public static DependencyEntry required( + @NotNull String pluginName, + @NotNull String description + ) { + return new DependencyEntry( + pluginName, DependencyPriority.REQUIRED, null, description); + } + + /** + * A recommended dependency — warning logged if missing. + */ + @NotNull + public static DependencyEntry recommended( + @NotNull String pluginName, + @NotNull String description + ) { + return new DependencyEntry( + pluginName, DependencyPriority.RECOMMENDED, null, description); + } + + /** + * An optional dependency — info logged if missing. + */ + @NotNull + public static DependencyEntry optional( + @NotNull String pluginName, + @NotNull String description + ) { + return new DependencyEntry( + pluginName, DependencyPriority.OPTIONAL, null, description); + } + + /** + * A recommended dependency with a minimum version requirement. + */ + @NotNull + public static DependencyEntry recommended( + @NotNull String pluginName, + @NotNull String minimumVersion, + @NotNull String description + ) { + return new DependencyEntry( + pluginName, DependencyPriority.RECOMMENDED, + minimumVersion, description); + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/dependency/DependencyPriority.java b/src/main/java/dev/mzcy/core/dependency/DependencyPriority.java new file mode 100644 index 0000000..f60358b --- /dev/null +++ b/src/main/java/dev/mzcy/core/dependency/DependencyPriority.java @@ -0,0 +1,16 @@ +package dev.mzcy.core.dependency; + +/** + * How critical a missing dependency is. + * + *

+ */ +public enum DependencyPriority { + REQUIRED, + RECOMMENDED, + OPTIONAL +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/profiling/ProfilingInterceptor.java b/src/main/java/dev/mzcy/core/profiling/ProfilingInterceptor.java new file mode 100644 index 0000000..a956511 --- /dev/null +++ b/src/main/java/dev/mzcy/core/profiling/ProfilingInterceptor.java @@ -0,0 +1,80 @@ +package dev.mzcy.core.profiling; + +import lombok.RequiredArgsConstructor; +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Method; + +/** + * Wraps {@link Timed}-annotated method calls with nanosecond-precision timing. + * + *

Used internally by {@link ProfilingProxyFactory}. + * Plugin code never interacts with this directly. + */ +@Log +@RequiredArgsConstructor +public final class ProfilingInterceptor { + + @NotNull + private final TimingRegistry registry; + + /** + * Times the given method invocation and records the result. + * + * @param method the annotated method + * @param invoker the actual method body + * @return the method's return value + * @throws Exception if the underlying method throws + */ + public Object intercept( + @NotNull Method method, + @NotNull MethodInvoker invoker + ) throws Exception { + final Timed annotation = method.getAnnotation(Timed.class); + if (annotation == null) { + return invoker.invoke(); + } + + final String key = resolveKey(annotation, method); + final long start = System.nanoTime(); + + try { + return invoker.invoke(); + } finally { + final long elapsed = System.nanoTime() - start; + registry.record(key, elapsed); + + // Slow method warning + if (annotation.warnOnSlow()) { + final long elapsedMs = elapsed / 1_000_000L; + if (elapsedMs >= annotation.warnThresholdMs()) { + log.warning("[Profiling] Slow method detected: " + + key + " took " + elapsedMs + "ms " + + "(threshold: " + annotation.warnThresholdMs() + "ms)"); + } + } + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + @NotNull + private String resolveKey( + @NotNull Timed annotation, + @NotNull Method method + ) { + if (!annotation.value().isBlank()) { + return annotation.value(); + } + return method.getDeclaringClass().getSimpleName() + + "." + method.getName(); + } + + @FunctionalInterface + public interface MethodInvoker { + Object invoke() throws Exception; + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/profiling/ProfilingManager.java b/src/main/java/dev/mzcy/core/profiling/ProfilingManager.java new file mode 100644 index 0000000..eba398e --- /dev/null +++ b/src/main/java/dev/mzcy/core/profiling/ProfilingManager.java @@ -0,0 +1,154 @@ +package dev.mzcy.core.profiling; + +import lombok.Getter; +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Central manager for the profiling system. + * + *

Owns the {@link TimingRegistry} and {@link ProfilingProxyFactory}, + * and exposes the public profiling API. + * + *

Components with {@link Timed}-annotated methods are automatically + * wrapped via {@link #wrapIfNeeded(Object)} during DI resolution. + * + *

All timing data is accessible from: + *

+ */ +@Log +public final class ProfilingManager { + + @Getter + private final TimingRegistry registry = new TimingRegistry(); + + private final ProfilingProxyFactory proxyFactory; + + public ProfilingManager() { + final ProfilingInterceptor interceptor = + new ProfilingInterceptor(registry); + this.proxyFactory = new ProfilingProxyFactory(interceptor); + } + + // ========================================================================= + // Proxy wrapping + // ========================================================================= + + /** + * Wraps the given component in a timing proxy if it has + * {@link Timed}-annotated methods. + * + *

Called automatically during DI resolution. + * + * @param instance the component to potentially wrap + * @param the component type + * @return the wrapped or original instance + */ + @NotNull + public T wrapIfNeeded(@NotNull T instance) { + if (!proxyFactory.needsProxy(instance.getClass())) { + return instance; + } + log.fine(() -> "Wrapping " + instance.getClass().getSimpleName() + + " with profiling proxy."); + return proxyFactory.wrap(instance); + } + + // ========================================================================= + // Manual timing + // ========================================================================= + + /** + * Manually records an execution time. + * Use this for code blocks that cannot be annotated. + * + *

Example: + *

{@code
+     * final long start = System.nanoTime();
+     * heavyOperation();
+     * profilingManager.record("myPlugin.heavyOp", System.nanoTime() - start);
+     * }
+ * + * @param key the timing key + * @param nanos elapsed nanoseconds + */ + public void record(@NotNull String key, long nanos) { + registry.record(key, nanos); + } + + /** + * Times a runnable block manually. + * + *

Example: + *

{@code
+     * profilingManager.time("myPlugin.rebuild", () -> {
+     *     leaderboard.rebuild();
+     * });
+     * }
+ * + * @param key the timing key + * @param runnable the code to time + */ + public void time(@NotNull String key, @NotNull Runnable runnable) { + final long start = System.nanoTime(); + try { + runnable.run(); + } finally { + registry.record(key, System.nanoTime() - start); + } + } + + /** + * Times a supplier block and returns its result. + * + * @param key the timing key + * @param supplier the code to time + * @param the return type + * @return the supplier's result + */ + @NotNull + public T time( + @NotNull String key, + @NotNull java.util.function.Supplier supplier + ) { + final long start = System.nanoTime(); + try { + return supplier.get(); + } finally { + registry.record(key, System.nanoTime() - start); + } + } + + // ========================================================================= + // Convenience queries + // ========================================================================= + + /** + * Returns the top 5 slowest methods by average execution time. + */ + @NotNull + public List getSlowest() { + return registry.getSlowest(5); + } + + /** + * Returns the top 5 most frequently called methods. + */ + @NotNull + public List getMostCalled() { + return registry.getMostCalled(5); + } + + /** + * Resets all timing statistics. + */ + public void resetAll() { + registry.resetAll(); + log.fine("Profiling stats reset."); + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/profiling/ProfilingProxyFactory.java b/src/main/java/dev/mzcy/core/profiling/ProfilingProxyFactory.java new file mode 100644 index 0000000..00c9cae --- /dev/null +++ b/src/main/java/dev/mzcy/core/profiling/ProfilingProxyFactory.java @@ -0,0 +1,99 @@ +package dev.mzcy.core.profiling; + +import lombok.RequiredArgsConstructor; +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.*; +import java.util.Arrays; + +/** + * Creates JDK dynamic proxies that apply {@link Timed} timing + * around annotated methods. + * + *

Mirrors the structure of + * {@link dev.mzcy.core.cache.CacheProxyFactory} and + * {@link dev.mzcy.core.permission.PermissionProxyFactory}. + * Only works for classes that implement at least one interface. + */ +@Log +@RequiredArgsConstructor +public final class ProfilingProxyFactory { + + @NotNull + private final ProfilingInterceptor interceptor; + + /** + * Returns true if the class has any {@link Timed}-annotated method. + */ + public boolean needsProxy(@NotNull Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(Timed.class)); + } + + /** + * Wraps the given instance in a timing proxy. + * Returns the original if no interface is available. + */ + @NotNull + @SuppressWarnings("unchecked") + public T wrap(@NotNull T instance) { + final Class[] interfaces = instance.getClass().getInterfaces(); + if (interfaces.length == 0) { + log.fine(() -> "Cannot proxy " + + instance.getClass().getSimpleName() + + " for @Timed — no interfaces. Timing will not apply."); + return instance; + } + + return (T) Proxy.newProxyInstance( + instance.getClass().getClassLoader(), + interfaces, + new TimingInvocationHandler(instance, interceptor) + ); + } + + // ========================================================================= + // Invocation handler + // ========================================================================= + + private static final class TimingInvocationHandler + implements InvocationHandler { + + private final Object target; + private final ProfilingInterceptor interceptor; + + TimingInvocationHandler( + @NotNull Object target, + @NotNull ProfilingInterceptor interceptor + ) { + this.target = target; + this.interceptor = interceptor; + } + + @Override + public Object invoke( + Object proxy, + Method method, + Object[] args + ) throws Throwable { + final Method targetMethod; + try { + targetMethod = target.getClass() + .getDeclaredMethod(method.getName(), + method.getParameterTypes()); + } catch (NoSuchMethodException ex) { + return method.invoke(target, args); + } + + if (!targetMethod.isAnnotationPresent(Timed.class)) { + return method.invoke(target, args); + } + + return interceptor.intercept(targetMethod, () -> { + targetMethod.setAccessible(true); + return targetMethod.invoke(target, args); + }); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/profiling/Timed.java b/src/main/java/dev/mzcy/core/profiling/Timed.java new file mode 100644 index 0000000..3cbac0c --- /dev/null +++ b/src/main/java/dev/mzcy/core/profiling/Timed.java @@ -0,0 +1,56 @@ +package dev.mzcy.core.profiling; + +import java.lang.annotation.*; + +/** + * Marks a method for automatic execution time measurement. + * + *

When placed on a method in a {@link dev.mzcy.core.annotation.Component}, + * the {@link ProfilingInterceptor} wraps every call with a nanosecond-precision + * timer. Results are aggregated in the {@link TimingRegistry} and exposed + * in the debug overlay under the Profiling section. + * + *

Example: + *

{@code
+ * @Component
+ * public class LeaderboardService implements LeaderboardPort {
+ *
+ *     @Timed("leaderboard.rebuild")
+ *     @Override
+ *     public void rebuild() {
+ *         // expensive operation
+ *     }
+ *
+ *     @Timed   // uses ClassName.methodName as key
+ *     public List getTop(int limit) { ... }
+ * }
+ * }
+ * + *

Keys are shown in {@code /core debug} and can be queried via + * {@link TimingRegistry#getSummary(String)}. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Timed { + + /** + * The timing key used in the registry and debug overlay. + * Defaults to {@code "ClassName.methodName"} if blank. + */ + String value() default ""; + + /** + * Whether to log a warning when the method exceeds + * {@link #warnThresholdMs()} milliseconds. + * Defaults to {@code true}. + */ + boolean warnOnSlow() default true; + + /** + * Threshold in milliseconds above which a warning is logged. + * Only relevant when {@link #warnOnSlow()} is {@code true}. + * Defaults to {@code 50} ms (roughly 1 server tick). + */ + long warnThresholdMs() default 50L; +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/profiling/TimingRegistry.java b/src/main/java/dev/mzcy/core/profiling/TimingRegistry.java new file mode 100644 index 0000000..874d928 --- /dev/null +++ b/src/main/java/dev/mzcy/core/profiling/TimingRegistry.java @@ -0,0 +1,112 @@ +package dev.mzcy.core.profiling; + +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Central registry for all {@link TimingSummary} instances. + * + *

Entries are created lazily when a {@link Timed}-annotated method + * is first invoked. Accessible from the debug overlay and + * {@code /core debug}. + */ +public final class TimingRegistry { + + private final ConcurrentHashMap summaries + = new ConcurrentHashMap<>(); + + // ========================================================================= + // Recording + // ========================================================================= + + /** + * Records an execution time for the given key. + * Creates the summary entry if it does not yet exist. + * + * @param key the timing key + * @param nanos elapsed time in nanoseconds + */ + public void record(@NotNull String key, long nanos) { + summaries.computeIfAbsent(key, TimingSummary::new).record(nanos); + } + + // ========================================================================= + // Lookup + // ========================================================================= + + /** + * Returns the timing summary for the given key, or empty if not recorded. + */ + @NotNull + public Optional getSummary(@NotNull String key) { + return Optional.ofNullable(summaries.get(key)); + } + + /** + * Returns all recorded summaries, sorted by key. + */ + @NotNull + public List getAll() { + return summaries.values().stream() + .sorted(Comparator.comparing(TimingSummary::getKey)) + .toList(); + } + + /** + * Returns the top N slowest methods by average execution time. + * + * @param n the number of entries to return + */ + @NotNull + public List getSlowest(int n) { + return summaries.values().stream() + .filter(s -> s.getInvocationCount() > 0) + .sorted(Comparator.comparingDouble(TimingSummary::avgMs).reversed()) + .limit(n) + .toList(); + } + + /** + * Returns the top N most frequently called methods by invocation count. + */ + @NotNull + public List getMostCalled(int n) { + return summaries.values().stream() + .sorted(Comparator.comparingLong( + TimingSummary::getInvocationCount).reversed()) + .limit(n) + .toList(); + } + + /** + * Resets all timing statistics. + */ + public void resetAll() { + summaries.values().forEach(TimingSummary::reset); + } + + /** + * Resets a specific timing key. + */ + public void reset(@NotNull String key) { + final TimingSummary summary = summaries.get(key); + if (summary != null) summary.reset(); + } + + /** + * Returns the number of tracked timing keys. + */ + public int size() { + return summaries.size(); + } + + /** + * Returns true if any timing data has been recorded. + */ + public boolean hasData() { + return summaries.values().stream() + .anyMatch(s -> s.getInvocationCount() > 0); + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/profiling/TimingSummary.java b/src/main/java/dev/mzcy/core/profiling/TimingSummary.java new file mode 100644 index 0000000..2c2c464 --- /dev/null +++ b/src/main/java/dev/mzcy/core/profiling/TimingSummary.java @@ -0,0 +1,107 @@ +package dev.mzcy.core.profiling; + +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * Aggregated timing statistics for a single {@link Timed} key. + * + *

All fields are updated atomically — safe for concurrent access + * from multiple threads. + */ +@Getter +public final class TimingSummary { + + @NotNull private final String key; + + private final AtomicLong invocations = new AtomicLong(0); + private final AtomicLong totalNanos = new AtomicLong(0); + private final AtomicLong minNanos = new AtomicLong(Long.MAX_VALUE); + private final AtomicLong maxNanos = new AtomicLong(0); + private final AtomicLong lastNanos = new AtomicLong(0); + + TimingSummary(@NotNull String key) { + this.key = key; + } + + // ========================================================================= + // Recording + // ========================================================================= + + void record(long nanos) { + invocations.incrementAndGet(); + totalNanos.addAndGet(nanos); + lastNanos.set(nanos); + + // Update min + long current; + do { + current = minNanos.get(); + } while (nanos < current && !minNanos.compareAndSet(current, nanos)); + + // Update max + do { + current = maxNanos.get(); + } while (nanos > current && !maxNanos.compareAndSet(current, nanos)); + } + + // ========================================================================= + // Derived metrics + // ========================================================================= + + /** Returns the average execution time in milliseconds. */ + public double avgMs() { + final long inv = invocations.get(); + return inv == 0 ? 0.0 + : (totalNanos.get() / (double) inv) / 1_000_000.0; + } + + /** Returns the minimum execution time in milliseconds. */ + public double minMs() { + final long min = minNanos.get(); + return min == Long.MAX_VALUE ? 0.0 : min / 1_000_000.0; + } + + /** Returns the maximum execution time in milliseconds. */ + public double maxMs() { + return maxNanos.get() / 1_000_000.0; + } + + /** Returns the last execution time in milliseconds. */ + public double lastMs() { + return lastNanos.get() / 1_000_000.0; + } + + /** Returns the total accumulated time in milliseconds. */ + public double totalMs() { + return totalNanos.get() / 1_000_000.0; + } + + /** Returns the total number of invocations recorded. */ + public long getInvocationCount() { + return invocations.get(); + } + + /** + * Resets all statistics to zero. + */ + public void reset() { + invocations.set(0); + totalNanos.set(0); + minNanos.set(Long.MAX_VALUE); + maxNanos.set(0); + lastNanos.set(0); + } + + @Override + public String toString() { + return "TimingSummary{key=" + key + + ", invocations=" + invocations.get() + + ", avg=" + String.format("%.2f", avgMs()) + "ms" + + ", min=" + String.format("%.2f", minMs()) + "ms" + + ", max=" + String.format("%.2f", maxMs()) + "ms" + + "}"; + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/ratelimit/RateLimit.java b/src/main/java/dev/mzcy/core/ratelimit/RateLimit.java new file mode 100644 index 0000000..687e8cd --- /dev/null +++ b/src/main/java/dev/mzcy/core/ratelimit/RateLimit.java @@ -0,0 +1,81 @@ +package dev.mzcy.core.ratelimit; + +import java.lang.annotation.*; +import java.util.concurrent.TimeUnit; + +/** + * Applies a rate limit to a method or command. + * + *

Uses a token-bucket algorithm — each caller gets a bucket + * with {@link #permits()} tokens that refill at the configured rate. + * When the bucket is empty, the call is rejected. + * + *

Example: + *

{@code
+ * // Max 5 calls per second per player
+ * @RateLimit(permits = 5, per = 1, unit = TimeUnit.SECONDS)
+ * public void onInteract(Player player) { ... }
+ *
+ * // Max 10 API calls per minute, globally shared
+ * @RateLimit(
+ *     permits = 10,
+ *     per     = 1,
+ *     unit    = TimeUnit.MINUTES,
+ *     global  = true,
+ *     message = "API limit reached. Try again in ."
+ * )
+ * public Response callExternalApi(String endpoint) { ... }
+ *
+ * // Burst allowed — 20 tokens, refill 5 per second
+ * @RateLimit(permits = 5, per = 1, unit = TimeUnit.SECONDS, burst = 20)
+ * public void onChat(Player player, String message) { ... }
+ * }
+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RateLimit { + + /** + * Number of permits (tokens) that refill per {@link #per()} {@link #unit()}. + */ + int permits(); + + /** + * The time quantity for the refill period. + * Defaults to {@code 1}. + */ + long per() default 1L; + + /** + * The time unit for the refill period. + * Defaults to {@link TimeUnit#SECONDS}. + */ + TimeUnit unit() default TimeUnit.SECONDS; + + /** + * Maximum burst size — the bucket capacity. + * Defaults to {@link #permits()} (no burst beyond the refill rate). + * Set higher to allow short bursts above the sustained rate. + */ + int burst() default -1; // -1 = use permits value + + /** + * When {@code true}, a single bucket is shared across all callers. + * When {@code false} (default), each player/UUID gets their own bucket. + */ + boolean global() default false; + + /** + * MiniMessage message sent when the rate limit is exceeded. + * Supports {@code } placeholder for time until next token. + * Defaults to a generic message. + */ + String message() default "You are doing that too fast. Please wait ."; + + /** + * When {@code true}, no message is sent on rejection. + * Defaults to {@code false}. + */ + boolean silent() default false; +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/ratelimit/RateLimitInterceptor.java b/src/main/java/dev/mzcy/core/ratelimit/RateLimitInterceptor.java new file mode 100644 index 0000000..84a4210 --- /dev/null +++ b/src/main/java/dev/mzcy/core/ratelimit/RateLimitInterceptor.java @@ -0,0 +1,124 @@ +package dev.mzcy.core.ratelimit; + +import lombok.RequiredArgsConstructor; +import lombok.extern.java.Log; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Method; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * Enforces {@link RateLimit} constraints on method calls. + * + *

Maintains per-caller or global {@link TokenBucket}s. + * Caller identity is resolved from method arguments — the first + * {@link Player} parameter is used if present, otherwise falls + * back to the {@link dev.mzcy.core.permission.PermissionContext}. + */ +@Log +@RequiredArgsConstructor +public final class RateLimitInterceptor { + + private static final MiniMessage MINI = MiniMessage.miniMessage(); + + /** Global bucket key — used when {@link RateLimit#global()} is true. */ + private static final String GLOBAL_KEY = "__global__"; + + @NotNull + private final RateLimitRegistry registry; + + /** + * Checks the rate limit and invokes the method if allowed. + * + * @param method the annotated method + * @param args the method arguments + * @param invoker the actual method body + * @return the method result, or {@code null} if rate-limited + */ + @Nullable + public Object intercept( + @NotNull Method method, + @Nullable Object[] args, + @NotNull MethodInvoker invoker + ) throws Exception { + final RateLimit annotation = method.getAnnotation(RateLimit.class); + if (annotation == null) return invoker.invoke(); + + final String bucketKey = resolveBucketKey(annotation, args); + final String methodKey = method.getDeclaringClass().getSimpleName() + + "." + method.getName(); + + final TokenBucket bucket = registry.getOrCreate( + methodKey + ":" + bucketKey, annotation); + + if (bucket.tryConsume()) { + return invoker.invoke(); + } + + // Rate limited — send message if a Player is involved + if (!annotation.silent()) { + final Player player = resolvePlayer(args); + if (player != null) { + final long waitMs = bucket.millisUntilNextToken(); + final String msg = annotation.message() + .replace("", formatMs(waitMs)); + player.sendMessage(MINI.deserialize(msg)); + } + } + + log.fine(() -> "[RateLimit] Rejected: " + methodKey + + " for key: " + bucketKey); + return null; + } + + // ========================================================================= + // Helpers + // ========================================================================= + + @NotNull + private String resolveBucketKey( + @NotNull RateLimit annotation, + @Nullable Object[] args + ) { + if (annotation.global()) return GLOBAL_KEY; + + // Try to find a Player in the arguments + final Player player = resolvePlayer(args); + if (player != null) return player.getUniqueId().toString(); + +// // Fall back to PermissionContext +// final Player contextPlayer = +// (); TODO: Get current player by permission context +// if (contextPlayer != null) { +// return contextPlayer.getUniqueId().toString(); +// } + + return GLOBAL_KEY; + } + + @Nullable + private Player resolvePlayer(@Nullable Object[] args) { + if (args == null) return null; + for (final Object arg : args) { + if (arg instanceof Player p) return p; + } + return null; + } + + @NotNull + private String formatMs(long ms) { + if (ms < 1000) return ms + "ms"; + if (ms < 60_000) return String.format("%.1fs", ms / 1000.0); + return String.format("%dm %ds", ms / 60_000, (ms % 60_000) / 1000); + } + + @FunctionalInterface + public interface MethodInvoker { + Object invoke() throws Exception; + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/ratelimit/RateLimitManager.java b/src/main/java/dev/mzcy/core/ratelimit/RateLimitManager.java new file mode 100644 index 0000000..068126a --- /dev/null +++ b/src/main/java/dev/mzcy/core/ratelimit/RateLimitManager.java @@ -0,0 +1,175 @@ +package dev.mzcy.core.ratelimit; + +import lombok.Getter; +import lombok.extern.java.Log; +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.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * Central manager for the rate limiting system. + * + *

Provides: + *

    + *
  • Automatic proxy wrapping for {@link RateLimit}-annotated components
  • + *
  • Manual rate limit API without annotations
  • + *
  • Automatic bucket cleanup on player disconnect
  • + *
  • Debug stats for {@code /core debug}
  • + *
+ */ +@Log +public final class RateLimitManager implements Listener { + + @Getter + private final RateLimitRegistry registry; + private final RateLimitInterceptor interceptor; + private final RateLimitProxyFactory proxyFactory; + + public RateLimitManager(@NotNull Plugin plugin) { + this.registry = new RateLimitRegistry(); + this.interceptor = new RateLimitInterceptor(registry); + this.proxyFactory = new RateLimitProxyFactory(interceptor); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + // ========================================================================= + // Proxy wrapping + // ========================================================================= + + @NotNull + public T wrapIfNeeded(@NotNull T instance) { + if (!proxyFactory.needsProxy(instance.getClass())) return instance; + log.fine(() -> "Wrapping " + + instance.getClass().getSimpleName() + + " with rate-limit proxy."); + return proxyFactory.wrap(instance); + } + + // ========================================================================= + // Manual API + // ========================================================================= + + /** + * Manually checks and consumes a rate limit token for a player. + * + *

Use this for rate-limiting code blocks that cannot be annotated. + * + * @param player the player to rate-limit + * @param key a unique key identifying the action + * @param permits tokens per interval + * @param per interval quantity + * @param unit interval unit + * @return {@code true} if allowed, {@code false} if rate-limited + */ + public boolean tryAcquire( + @NotNull Player player, + @NotNull String key, + int permits, + long per, + @NotNull TimeUnit unit + ) { + return tryAcquire(player.getUniqueId(), key, permits, per, unit); + } + + /** + * Manually checks a rate limit for a UUID. + */ + public boolean tryAcquire( + @NotNull UUID uuid, + @NotNull String key, + int permits, + long per, + @NotNull TimeUnit unit + ) { + final String bucketKey = key + ":" + uuid; + final TokenBucket bucket = registry.getOrCreate( + bucketKey, + buildAnnotation(permits, per, unit, permits, false) + ); + return bucket.tryConsume(); + } + + /** + * Manually checks a global rate limit. + */ + public boolean tryAcquireGlobal( + @NotNull String key, + int permits, + long per, + @NotNull TimeUnit unit + ) { + final TokenBucket bucket = registry.getOrCreate( + key + ":__global__", + buildAnnotation(permits, per, unit, permits, true) + ); + return bucket.tryConsume(); + } + + /** + * Returns the milliseconds until the next token is available + * for the given player and key. + */ + public long millisUntilNextToken( + @NotNull Player player, + @NotNull String key + ) { + return registry.get(key + ":" + player.getUniqueId()) + .map(TokenBucket::millisUntilNextToken) + .orElse(0L); + } + + /** + * Resets all rate limit buckets for a player. + * Useful for admin commands. + */ + public void resetPlayer(@NotNull Player player) { + registry.clearForPlayer(player.getUniqueId()); + log.fine(() -> "Reset rate limits for: " + player.getName()); + } + + /** + * Resets all rate limit buckets. + */ + public void resetAll() { + registry.clearAll(); + log.fine("All rate limits reset."); + } + + // ========================================================================= + // Events + // ========================================================================= + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(@NotNull PlayerQuitEvent event) { + // Clean up per-player buckets on disconnect + registry.clearForPlayer(event.getPlayer().getUniqueId()); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private RateLimit buildAnnotation( + int permits, long per, + @NotNull TimeUnit unit, + int burst, boolean global + ) { + return new RateLimit() { + @Override public Class annotationType() { return RateLimit.class; } + @Override public int permits() { return permits; } + @Override public long per() { return per; } + @Override public TimeUnit unit() { return unit; } + @Override public int burst() { return burst; } + @Override public boolean global() { return global; } + @Override public String message() { return "Rate limited."; } + @Override public boolean silent() { return true; } + }; + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/ratelimit/RateLimitProxyFactory.java b/src/main/java/dev/mzcy/core/ratelimit/RateLimitProxyFactory.java new file mode 100644 index 0000000..4e09490 --- /dev/null +++ b/src/main/java/dev/mzcy/core/ratelimit/RateLimitProxyFactory.java @@ -0,0 +1,81 @@ +package dev.mzcy.core.ratelimit; + +import lombok.RequiredArgsConstructor; +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.*; +import java.util.Arrays; + +/** + * Creates JDK dynamic proxies that enforce {@link RateLimit} constraints. + */ +@Log +@RequiredArgsConstructor +public final class RateLimitProxyFactory { + + @NotNull + private final RateLimitInterceptor interceptor; + + public boolean needsProxy(@NotNull Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(RateLimit.class)); + } + + @NotNull + @SuppressWarnings("unchecked") + public T wrap(@NotNull T instance) { + final Class[] interfaces = instance.getClass().getInterfaces(); + if (interfaces.length == 0) { + log.fine(() -> "Cannot proxy " + + instance.getClass().getSimpleName() + + " for @RateLimit — no interfaces."); + return instance; + } + return (T) Proxy.newProxyInstance( + instance.getClass().getClassLoader(), + interfaces, + new RateLimitInvocationHandler(instance, interceptor) + ); + } + + private static final class RateLimitInvocationHandler + implements InvocationHandler { + + private final Object target; + private final RateLimitInterceptor interceptor; + + RateLimitInvocationHandler( + @NotNull Object target, + @NotNull RateLimitInterceptor interceptor + ) { + this.target = target; + this.interceptor = interceptor; + } + + @Override + public Object invoke( + Object proxy, + Method method, + Object[] args + ) throws Throwable { + final Method targetMethod; + try { + targetMethod = target.getClass() + .getDeclaredMethod(method.getName(), + method.getParameterTypes()); + } catch (NoSuchMethodException ex) { + return method.invoke(target, args); + } + + if (!targetMethod.isAnnotationPresent(RateLimit.class)) { + return method.invoke(target, args); + } + + return interceptor.intercept(targetMethod, args, () -> { + targetMethod.setAccessible(true); + return targetMethod.invoke(target, args); + }); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/ratelimit/RateLimitRegistry.java b/src/main/java/dev/mzcy/core/ratelimit/RateLimitRegistry.java new file mode 100644 index 0000000..c8fabf5 --- /dev/null +++ b/src/main/java/dev/mzcy/core/ratelimit/RateLimitRegistry.java @@ -0,0 +1,100 @@ +package dev.mzcy.core.ratelimit; + +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * Registry for all {@link TokenBucket} instances. + * + *

Buckets are created lazily and keyed by + * {@code "ClassName.methodName:callerUuid"} or + * {@code "ClassName.methodName:__global__"}. + */ +public final class RateLimitRegistry { + + private final ConcurrentHashMap buckets + = new ConcurrentHashMap<>(); + + /** + * Returns the bucket for the given key, creating one if absent. + * + * @param key the bucket key + * @param annotation the rate limit configuration + * @return the existing or newly created bucket + */ + @NotNull + public TokenBucket getOrCreate( + @NotNull String key, + @NotNull RateLimit annotation + ) { + return buckets.computeIfAbsent(key, k -> createBucket(annotation)); + } + + /** + * Returns the bucket for the given key if it exists. + */ + @NotNull + public Optional get(@NotNull String key) { + return Optional.ofNullable(buckets.get(key)); + } + + /** + * Resets a specific bucket by key. + */ + public void reset(@NotNull String key) { + final TokenBucket bucket = buckets.get(key); + if (bucket != null) bucket.reset(); + } + + /** + * Removes all buckets for a specific caller UUID. + * Useful for clearing limits when a player disconnects. + */ + public void clearForPlayer(@NotNull java.util.UUID uuid) { + final String prefix = uuid.toString(); + buckets.keySet().removeIf(k -> k.endsWith(":" + prefix)); + } + + /** + * Removes all buckets. + */ + public void clearAll() { + buckets.clear(); + } + + /** + * Returns the total number of active buckets. + */ + public int size() { + return buckets.size(); + } + + /** + * Returns aggregate stats across all buckets. + * Key = bucket key, value = rejected / total requests. + */ + @NotNull + public Map getStats() { + final Map stats = new LinkedHashMap<>(); + buckets.forEach((key, bucket) -> stats.put(key, + new long[]{bucket.getRejectedRequests(), bucket.getTotalRequests()})); + return Collections.unmodifiableMap(stats); + } + + // ========================================================================= + // Internal + // ========================================================================= + + @NotNull + private TokenBucket createBucket(@NotNull RateLimit annotation) { + final int capacity = annotation.burst() > 0 + ? annotation.burst() : annotation.permits(); + final long intervalNs = annotation.unit() + .toNanos(annotation.per()); + + return new TokenBucket(capacity, annotation.permits(), intervalNs); + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/ratelimit/TokenBucket.java b/src/main/java/dev/mzcy/core/ratelimit/TokenBucket.java new file mode 100644 index 0000000..c48448e --- /dev/null +++ b/src/main/java/dev/mzcy/core/ratelimit/TokenBucket.java @@ -0,0 +1,123 @@ +package dev.mzcy.core.ratelimit; + +import lombok.Getter; + +/** + * A thread-safe token-bucket rate limiter. + * + *

Tokens accumulate at a fixed refill rate up to the bucket capacity. + * Each {@link #tryConsume()} call attempts to take one token. + * If the bucket is empty, the call fails and returns false. + * + *

Tokens are refilled lazily on each call — no background thread needed. + */ +public final class TokenBucket { + + /** Maximum number of tokens (burst capacity). */ + private final int capacity; + + /** Tokens added per refill interval. */ + private final int refillAmount; + + /** Refill interval in nanoseconds. */ + private final long refillIntervalNanos; + + /** Current token count — may be fractional internally. */ + private double tokens; + + /** Timestamp of last refill check (nanoseconds). */ + private long lastRefillNanos; + + /** Total number of requests attempted. */ + @Getter + private long totalRequests = 0; + + /** Total number of requests rejected. */ + @Getter + private long rejectedRequests = 0; + + public TokenBucket( + int capacity, + int refillAmount, + long refillIntervalNanos + ) { + this.capacity = capacity; + this.refillAmount = refillAmount; + this.refillIntervalNanos = refillIntervalNanos; + this.tokens = capacity; // start full + this.lastRefillNanos = System.nanoTime(); + } + + // ========================================================================= + // Core API + // ========================================================================= + + /** + * Attempts to consume one token. + * + * @return {@code true} if a token was consumed (request allowed), + * {@code false} if the bucket is empty (request rejected) + */ + public synchronized boolean tryConsume() { + refill(); + totalRequests++; + + if (tokens >= 1.0) { + tokens -= 1.0; + return true; + } + + rejectedRequests++; + return false; + } + + /** + * Returns the estimated time in milliseconds until the next token + * becomes available. Returns {@code 0} if a token is already available. + */ + public synchronized long millisUntilNextToken() { + refill(); + if (tokens >= 1.0) return 0L; + + final double tokensNeeded = 1.0 - tokens; + final double nanosPerToken = (double) refillIntervalNanos / refillAmount; + return Math.max(0L, (long) (tokensNeeded * nanosPerToken) / 1_000_000L); + } + + /** + * Returns the current token count (may be fractional). + */ + public synchronized double getTokens() { + refill(); + return tokens; + } + + /** + * Resets this bucket to full capacity. + */ + public synchronized void reset() { + tokens = capacity; + lastRefillNanos = System.nanoTime(); + totalRequests = 0; + rejectedRequests = 0; + } + + // ========================================================================= + // Refill + // ========================================================================= + + private void refill() { + final long now = System.nanoTime(); + final long elapsed = now - lastRefillNanos; + + if (elapsed <= 0) return; + + final double tokensToAdd = + ((double) elapsed / refillIntervalNanos) * refillAmount; + + if (tokensToAdd >= 0.001) { // avoid floating-point noise + tokens = Math.min(capacity, tokens + tokensToAdd); + lastRefillNanos = now; + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/retry/BackoffStrategy.java b/src/main/java/dev/mzcy/core/retry/BackoffStrategy.java new file mode 100644 index 0000000..cefb4d2 --- /dev/null +++ b/src/main/java/dev/mzcy/core/retry/BackoffStrategy.java @@ -0,0 +1,53 @@ +package dev.mzcy.core.retry; + +/** + * Defines how long to wait between retry attempts. + * + *

    + *
  • {@link #FIXED} — same delay every time
  • + *
  • {@link #LINEAR} — delay increases linearly (attempt × baseDelay)
  • + *
  • {@link #EXPONENTIAL} — delay doubles each attempt (2^attempt × baseDelay)
  • + *
  • {@link #RANDOM} — random delay between 0 and baseDelay
  • + *
+ */ +public enum BackoffStrategy { + + FIXED { + @Override + public long delayMs(int attempt, long baseDelayMs, long maxDelayMs) { + return Math.min(baseDelayMs, maxDelayMs); + } + }, + + LINEAR { + @Override + public long delayMs(int attempt, long baseDelayMs, long maxDelayMs) { + return Math.min(baseDelayMs * attempt, maxDelayMs); + } + }, + + EXPONENTIAL { + @Override + public long delayMs(int attempt, long baseDelayMs, long maxDelayMs) { + final long delay = baseDelayMs * (1L << Math.min(attempt - 1, 30)); + return Math.min(delay, maxDelayMs); + } + }, + + RANDOM { + @Override + public long delayMs(int attempt, long baseDelayMs, long maxDelayMs) { + return (long) (Math.random() * Math.min(baseDelayMs, maxDelayMs)); + } + }; + + /** + * Returns the delay in milliseconds before the given attempt number. + * + * @param attempt the current attempt number (1-based) + * @param baseDelayMs the base delay in milliseconds + * @param maxDelayMs the maximum allowed delay in milliseconds + * @return the delay to wait before this attempt + */ + public abstract long delayMs(int attempt, long baseDelayMs, long maxDelayMs); +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/retry/Retry.java b/src/main/java/dev/mzcy/core/retry/Retry.java new file mode 100644 index 0000000..ecefda5 --- /dev/null +++ b/src/main/java/dev/mzcy/core/retry/Retry.java @@ -0,0 +1,91 @@ +package dev.mzcy.core.retry; + +import java.lang.annotation.*; + +/** + * Marks a method for automatic retry on exception. + * + *

When a {@link Timed}-annotated method throws a matching exception, + * the {@link RetryInterceptor} waits according to the configured + * {@link BackoffStrategy} and retries up to {@link #attempts()} times. + * + *

If all attempts fail, the last exception is rethrown wrapped in a + * {@link RetryExhaustedException}. + * + *

Example: + *

{@code
+ * // Retry DB calls up to 3 times with exponential backoff
+ * @Retry(attempts = 3, backoff = BackoffStrategy.EXPONENTIAL, delayMs = 200)
+ * public PlayerData loadPlayer(UUID uuid) {
+ *     return database.find(uuid);
+ * }
+ *
+ * // Retry only on specific exceptions
+ * @Retry(
+ *     attempts  = 5,
+ *     backoff   = BackoffStrategy.LINEAR,
+ *     delayMs   = 100,
+ *     retryOn   = {SQLException.class, TimeoutException.class}
+ * )
+ * public void savePlayer(UUID uuid, PlayerData data) { ... }
+ *
+ * // With max delay cap
+ * @Retry(
+ *     attempts   = 10,
+ *     backoff    = BackoffStrategy.EXPONENTIAL,
+ *     delayMs    = 100,
+ *     maxDelayMs = 5000   // never wait more than 5 seconds
+ * )
+ * public Response callExternalApi(String endpoint) { ... }
+ * }
+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Retry { + + /** + * Maximum number of attempts (including the first call). + * Must be ≥ 2. Defaults to {@code 3}. + */ + int attempts() default 3; + + /** + * The backoff strategy used to compute wait time between attempts. + * Defaults to {@link BackoffStrategy#EXPONENTIAL}. + */ + BackoffStrategy backoff() default BackoffStrategy.EXPONENTIAL; + + /** + * Base delay in milliseconds between attempts. + * The exact wait depends on the {@link #backoff()} strategy. + * Defaults to {@code 200} ms. + */ + long delayMs() default 200L; + + /** + * Maximum delay cap in milliseconds — the wait never exceeds this. + * Defaults to {@code 10_000} ms (10 seconds). + */ + long maxDelayMs() default 10_000L; + + /** + * Exception types that trigger a retry. + * All other exceptions propagate immediately without retrying. + * Defaults to {@link Exception} — retries on any exception. + */ + Class[] retryOn() default {Exception.class}; + + /** + * Exception types that should never trigger a retry, + * even if they match {@link #retryOn()}. + * Useful for excluding validation or illegal-argument errors. + */ + Class[] noRetryOn() default {}; + + /** + * Whether to log a warning on each failed attempt. + * Defaults to {@code true}. + */ + boolean logAttempts() default true; +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/retry/RetryExhaustedException.java b/src/main/java/dev/mzcy/core/retry/RetryExhaustedException.java new file mode 100644 index 0000000..a9eae67 --- /dev/null +++ b/src/main/java/dev/mzcy/core/retry/RetryExhaustedException.java @@ -0,0 +1,40 @@ +package dev.mzcy.core.retry; + +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +/** + * Thrown when all retry attempts of a {@link Retry}-annotated method + * have been exhausted. + * + *

The {@link #getCause()} always contains the last exception + * thrown by the underlying method. + */ +@Getter +public final class RetryExhaustedException extends RuntimeException { + + /** + * -- GETTER -- + * Returns the number of attempts made. + */ + private final int attempts; + /** + * -- GETTER -- + * Returns the total elapsed time across all attempts in milliseconds. + */ + private final long totalElapsedMs; + + public RetryExhaustedException( + @NotNull String methodName, + int attempts, + long totalElapsedMs, + @NotNull Throwable lastCause + ) { + super("All " + attempts + " attempt(s) failed for [" + + methodName + "] after " + totalElapsedMs + "ms", + lastCause); + this.attempts = attempts; + this.totalElapsedMs = totalElapsedMs; + } + +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/retry/RetryInterceptor.java b/src/main/java/dev/mzcy/core/retry/RetryInterceptor.java new file mode 100644 index 0000000..8819c11 --- /dev/null +++ b/src/main/java/dev/mzcy/core/retry/RetryInterceptor.java @@ -0,0 +1,116 @@ +package dev.mzcy.core.retry; + +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.logging.Level; + +/** + * Wraps {@link Retry}-annotated method calls with automatic retry logic. + * + *

Used internally by {@link RetryProxyFactory}. + */ +@Log +public final class RetryInterceptor { + + /** + * Executes the method with retry semantics. + * + * @param method the annotated method + * @param invoker the actual method body + * @return the method's return value + * @throws RetryExhaustedException if all attempts fail + * @throws Exception if the exception is not retryable + */ + public Object intercept( + @NotNull Method method, + @NotNull MethodInvoker invoker + ) throws Exception { + final Retry annotation = method.getAnnotation(Retry.class); + if (annotation == null) return invoker.invoke(); + + final String methodName = method.getDeclaringClass().getSimpleName() + + "." + method.getName(); + + final long startTime = System.currentTimeMillis(); + Throwable lastCause = null; + + for (int attempt = 1; attempt <= annotation.attempts(); attempt++) { + try { + return invoker.invoke(); + + } catch (Throwable ex) { + + // Check noRetryOn first — immediate rethrow + if (matchesAny(ex, annotation.noRetryOn())) { + rethrow(ex); + } + + // Check if we should retry this exception type + if (!matchesAny(ex, annotation.retryOn())) { + rethrow(ex); + } + + lastCause = ex; + + if (attempt == annotation.attempts()) break; // no more retries + + final long delayMs = annotation.backoff().delayMs( + attempt, annotation.delayMs(), annotation.maxDelayMs()); + + if (annotation.logAttempts()) { + log.log(Level.WARNING, + "[Retry] Attempt " + attempt + "/" + annotation.attempts() + + " failed for [" + methodName + "]: " + + ex.getClass().getSimpleName() + + ": " + ex.getMessage() + + " — retrying in " + delayMs + "ms"); + } + + if (delayMs > 0) { + try { + Thread.sleep(delayMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RetryExhaustedException( + methodName, attempt, + System.currentTimeMillis() - startTime, + lastCause); + } + } + } + } + + throw new RetryExhaustedException( + methodName, + annotation.attempts(), + System.currentTimeMillis() - startTime, + lastCause + ); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private boolean matchesAny( + @NotNull Throwable ex, + @NotNull Class[] types + ) { + if (types.length == 0) return false; + return Arrays.stream(types) + .anyMatch(type -> type.isInstance(ex)); + } + + @SuppressWarnings("unchecked") + private void rethrow(@NotNull Throwable ex) throws T { + throw (T) ex; + } + + @FunctionalInterface + public interface MethodInvoker { + Object invoke() throws Exception; + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/retry/RetryManager.java b/src/main/java/dev/mzcy/core/retry/RetryManager.java new file mode 100644 index 0000000..ec5d010 --- /dev/null +++ b/src/main/java/dev/mzcy/core/retry/RetryManager.java @@ -0,0 +1,155 @@ +package dev.mzcy.core.retry; + +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * Central manager for the retry system. + * + *

Provides: + *

    + *
  • Automatic proxy wrapping for {@link Retry}-annotated components
  • + *
  • Manual retry API for code blocks
  • + *
  • Async retry with {@link CompletableFuture}
  • + *
+ */ +@Log +public final class RetryManager { + + private static final Executor ASYNC_EXECUTOR = Executors.newCachedThreadPool( + r -> { + final Thread t = new Thread(r, "core-retry-async"); + t.setDaemon(true); + return t; + } + ); + + private final RetryInterceptor interceptor; + private final RetryProxyFactory proxyFactory; + + public RetryManager() { + this.interceptor = new RetryInterceptor(); + this.proxyFactory = new RetryProxyFactory(interceptor); + } + + // ========================================================================= + // Proxy wrapping + // ========================================================================= + + @NotNull + public T wrapIfNeeded(@NotNull T instance) { + if (!proxyFactory.needsProxy(instance.getClass())) return instance; + log.fine(() -> "Wrapping " + + instance.getClass().getSimpleName() + + " with retry proxy."); + return proxyFactory.wrap(instance); + } + + // ========================================================================= + // Manual retry API + // ========================================================================= + + /** + * Retries a callable block with the given configuration. + * + *

Blocks the calling thread during retries. + * + * @param callable the operation to retry + * @param attempts maximum attempts (including first) + * @param backoff the backoff strategy + * @param delayMs base delay in milliseconds + * @param the return type + * @return the result of the first successful attempt + * @throws RetryExhaustedException if all attempts fail + */ + @Nullable + public T retry( + @NotNull Callable callable, + int attempts, + @NotNull BackoffStrategy backoff, + long delayMs + ) { + return retry(callable, attempts, backoff, delayMs, 10_000L); + } + + /** + * Retries a callable block with a custom max delay. + */ + @Nullable + public T retry( + @NotNull Callable callable, + int attempts, + @NotNull BackoffStrategy backoff, + long delayMs, + long maxDelayMs + ) { + final long start = System.currentTimeMillis(); + Throwable last = null; + + for (int attempt = 1; attempt <= attempts; attempt++) { + try { + return callable.call(); + } catch (Exception ex) { + last = ex; + + if (attempt == attempts) break; + + final long wait = backoff.delayMs(attempt, delayMs, maxDelayMs); + log.warning("[Retry] Manual attempt " + attempt + "/" + attempts + + " failed: " + ex.getMessage() + + " — retrying in " + wait + "ms"); + + if (wait > 0) { + try { Thread.sleep(wait); } + catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + + throw new RetryExhaustedException( + "manual", attempts, + System.currentTimeMillis() - start, + last != null ? last : new RuntimeException("unknown") + ); + } + + /** + * Retries a runnable block with fixed backoff. + * Convenience overload for void operations. + */ + public void retry( + @NotNull Runnable runnable, + int attempts, + long delayMs + ) { + retry(() -> { runnable.run(); return null; }, + attempts, BackoffStrategy.FIXED, delayMs); + } + + /** + * Retries a callable asynchronously. + * Returns a {@link CompletableFuture} that completes on success + * or completes exceptionally with {@link RetryExhaustedException}. + */ + @NotNull + public CompletableFuture retryAsync( + @NotNull Callable callable, + int attempts, + @NotNull BackoffStrategy backoff, + long delayMs + ) { + return CompletableFuture.supplyAsync(() -> + retry(callable, attempts, backoff, delayMs), + ASYNC_EXECUTOR + ); + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/retry/RetryProxyFactory.java b/src/main/java/dev/mzcy/core/retry/RetryProxyFactory.java new file mode 100644 index 0000000..0b7ca0b --- /dev/null +++ b/src/main/java/dev/mzcy/core/retry/RetryProxyFactory.java @@ -0,0 +1,83 @@ +package dev.mzcy.core.retry; + +import lombok.RequiredArgsConstructor; +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.*; +import java.util.Arrays; + +/** + * Creates JDK dynamic proxies that apply {@link Retry} semantics. + * + *

Mirrors {@link dev.mzcy.core.cache.CacheProxyFactory}. + */ +@Log +@RequiredArgsConstructor +public final class RetryProxyFactory { + + @NotNull + private final RetryInterceptor interceptor; + + public boolean needsProxy(@NotNull Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(Retry.class)); + } + + @NotNull + @SuppressWarnings("unchecked") + public T wrap(@NotNull T instance) { + final Class[] interfaces = instance.getClass().getInterfaces(); + if (interfaces.length == 0) { + log.fine(() -> "Cannot proxy " + + instance.getClass().getSimpleName() + + " for @Retry — no interfaces."); + return instance; + } + return (T) Proxy.newProxyInstance( + instance.getClass().getClassLoader(), + interfaces, + new RetryInvocationHandler(instance, interceptor) + ); + } + + private static final class RetryInvocationHandler + implements InvocationHandler { + + private final Object target; + private final RetryInterceptor interceptor; + + RetryInvocationHandler( + @NotNull Object target, + @NotNull RetryInterceptor interceptor + ) { + this.target = target; + this.interceptor = interceptor; + } + + @Override + public Object invoke( + Object proxy, + Method method, + Object[] args + ) throws Throwable { + final Method targetMethod; + try { + targetMethod = target.getClass() + .getDeclaredMethod(method.getName(), + method.getParameterTypes()); + } catch (NoSuchMethodException ex) { + return method.invoke(target, args); + } + + if (!targetMethod.isAnnotationPresent(Retry.class)) { + return method.invoke(target, args); + } + + return interceptor.intercept(targetMethod, () -> { + targetMethod.setAccessible(true); + return targetMethod.invoke(target, args); + }); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/ValidationException.java b/src/main/java/dev/mzcy/core/validation/ValidationException.java new file mode 100644 index 0000000..8f3a0f8 --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/ValidationException.java @@ -0,0 +1,37 @@ +package dev.mzcy.core.validation; + +import org.jetbrains.annotations.NotNull; + +import java.util.Collections; +import java.util.List; + +/** + * Thrown when one or more parameter constraints are violated. + * + *

Contains the full list of violations so callers can show + * all errors at once rather than failing on the first. + */ +public final class ValidationException extends RuntimeException { + + private final List violations; + + public ValidationException(@NotNull List violations) { + super("Validation failed: " + String.join("; ", violations)); + this.violations = Collections.unmodifiableList(violations); + } + + public ValidationException(@NotNull String violation) { + this(List.of(violation)); + } + + /** Returns all constraint violation messages. */ + @NotNull + public List getViolations() { + return violations; + } + + /** Returns true if there is more than one violation. */ + public boolean hasMultipleViolations() { + return violations.size() > 1; + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/ValidationInterceptor.java b/src/main/java/dev/mzcy/core/validation/ValidationInterceptor.java new file mode 100644 index 0000000..270f92e --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/ValidationInterceptor.java @@ -0,0 +1,177 @@ +package dev.mzcy.core.validation; + +import dev.mzcy.core.validation.constraints.*; +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.*; +import java.util.regex.PatternSyntaxException; + +/** + * Validates method parameters annotated with constraint annotations + * before the method executes. + * + *

Collects all violations rather than failing on the first, + * then throws a single {@link ValidationException} containing all messages. + */ +@Log +public final class ValidationInterceptor { + + /** + * Validates all parameters of the given method invocation. + * + * @param method the method being called + * @param args the argument values + * @param invoker the actual method body + * @return the method's return value + * @throws ValidationException if any constraint is violated + * @throws Exception if the underlying method throws + */ + public Object intercept( + @NotNull Method method, + @org.jetbrains.annotations.Nullable Object[] args, + @NotNull MethodInvoker invoker + ) throws Exception { + if (!method.isAnnotationPresent(Validate.class)) { + return invoker.invoke(); + } + + final List violations = new ArrayList<>(); + final Parameter[] params = method.getParameters(); + + if (args != null) { + for (int i = 0; i < params.length && i < args.length; i++) { + validateParameter( + params[i], args[i], + method.getDeclaringClass().getSimpleName() + + "." + method.getName() + + "[" + params[i].getName() + "]", + violations + ); + } + } + + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + + return invoker.invoke(); + } + + // ========================================================================= + // Per-parameter validation + // ========================================================================= + + private void validateParameter( + @NotNull Parameter param, + @org.jetbrains.annotations.Nullable Object value, + @NotNull String context, + @NotNull List violations + ) { + for (final Annotation annotation : param.getAnnotations()) { + final String violation = + checkConstraint(annotation, value, context); + if (violation != null) violations.add(violation); + } + } + + @org.jetbrains.annotations.Nullable + private String checkConstraint( + @NotNull Annotation annotation, + @org.jetbrains.annotations.Nullable Object value, + @NotNull String context + ) { + // @NotNull + if (annotation instanceof dev.mzcy.core.validation.constraints.NotNull a) { + if (value == null) return context + ": " + a.message(); + } + + // @NotBlank + else if (annotation instanceof NotBlank a) { + if (value == null || value.toString().isBlank()) { + return context + ": " + a.message(); + } + } + + // @Min + else if (annotation instanceof Min a) { + if (value instanceof Number n && n.longValue() < a.value()) { + return context + ": " + + a.message().replace("{value}", String.valueOf(a.value())); + } + } + + // @Max + else if (annotation instanceof Max a) { + if (value instanceof Number n && n.longValue() > a.value()) { + return context + ": " + + a.message().replace("{value}", String.valueOf(a.value())); + } + } + + // @Range + else if (annotation instanceof Range a) { + if (value instanceof Number n) { + final long v = n.longValue(); + if (v < a.min() || v > a.max()) { + return context + ": " + + a.message() + .replace("{min}", String.valueOf(a.min())) + .replace("{max}", String.valueOf(a.max())); + } + } + } + + // @Pattern + else if (annotation instanceof Pattern a) { + if (value != null) { + try { + if (!value.toString().matches(a.value())) { + return context + ": " + + a.message().replace("{value}", a.value()); + } + } catch (PatternSyntaxException ex) { + log.warning("Invalid regex in @Pattern: " + a.value()); + } + } + } + + // @Size + else if (annotation instanceof Size a) { + if (value != null) { + final int size = resolveSize(value); + if (size < a.min() || size > a.max()) { + return context + ": " + + a.message() + .replace("{min}", String.valueOf(a.min())) + .replace("{max}", String.valueOf(a.max())); + } + } + } + + // @Positive + else if (annotation instanceof Positive a) { + if (value instanceof Number n && n.doubleValue() <= 0) { + return context + ": " + a.message(); + } + } + + return null; + } + + private int resolveSize(@NotNull Object value) { + if (value instanceof String s) return s.length(); + if (value instanceof Collection c) return c.size(); + if (value instanceof Map m) return m.size(); + if (value instanceof Object[] arr) return arr.length; + return 0; + } + + @FunctionalInterface + public interface MethodInvoker { + Object invoke() throws Exception; + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/ValidationManager.java b/src/main/java/dev/mzcy/core/validation/ValidationManager.java new file mode 100644 index 0000000..f1af423 --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/ValidationManager.java @@ -0,0 +1,113 @@ +package dev.mzcy.core.validation; + +import dev.mzcy.core.validation.constraints.Validate; +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +/** + * Central manager for the validation system. + * + *

Components with {@link Validate}-annotated methods are automatically + * wrapped via {@link #wrapIfNeeded(Object)} during DI resolution. + * + *

Also exposes a manual validation API for validating arbitrary values + * outside of method interception. + */ +@Log +public final class ValidationManager { + + private final ValidationInterceptor interceptor; + private final ValidationProxyFactory proxyFactory; + + public ValidationManager() { + this.interceptor = new ValidationInterceptor(); + this.proxyFactory = new ValidationProxyFactory(interceptor); + } + + // ========================================================================= + // Proxy wrapping + // ========================================================================= + + @NotNull + public T wrapIfNeeded(@NotNull T instance) { + if (!proxyFactory.needsProxy(instance.getClass())) return instance; + log.fine(() -> "Wrapping " + + instance.getClass().getSimpleName() + + " with validation proxy."); + return proxyFactory.wrap(instance); + } + + // ========================================================================= + // Manual validation + // ========================================================================= + + /** + * Validates that a value is not null. + * + * @throws ValidationException if null + */ + @NotNull + public T requireNotNull( + @org.jetbrains.annotations.Nullable T value, + @NotNull String fieldName + ) { + if (value == null) { + throw new ValidationException(fieldName + ": must not be null"); + } + return value; + } + + /** + * Validates that a string is not blank. + * + * @throws ValidationException if blank + */ + @NotNull + public String requireNotBlank( + @org.jetbrains.annotations.Nullable String value, + @NotNull String fieldName + ) { + if (value == null || value.isBlank()) { + throw new ValidationException(fieldName + ": must not be blank"); + } + return value; + } + + /** + * Validates that a number is within a range. + * + * @throws ValidationException if out of range + */ + public T requireRange( + @NotNull T value, + long min, + long max, + @NotNull String fieldName + ) { + final long v = value.longValue(); + if (v < min || v > max) { + throw new ValidationException( + fieldName + ": must be between " + min + " and " + max + + " (got " + v + ")"); + } + return value; + } + + /** + * Validates that a string matches a regex pattern. + * + * @throws ValidationException if no match + */ + @NotNull + public String requirePattern( + @NotNull String value, + @NotNull String pattern, + @NotNull String fieldName + ) { + if (!value.matches(pattern)) { + throw new ValidationException( + fieldName + ": must match pattern '" + pattern + "'"); + } + return value; + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/ValidationProxyFactory.java b/src/main/java/dev/mzcy/core/validation/ValidationProxyFactory.java new file mode 100644 index 0000000..9b885bc --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/ValidationProxyFactory.java @@ -0,0 +1,84 @@ +package dev.mzcy.core.validation; + +import dev.mzcy.core.validation.constraints.Validate; +import lombok.RequiredArgsConstructor; +import lombok.extern.java.Log; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.*; +import java.util.Arrays; + +/** + * Creates JDK dynamic proxies that enforce {@link Validate} constraints. + * + *

Mirrors {@link dev.mzcy.core.cache.CacheProxyFactory}. + */ +@Log +@RequiredArgsConstructor +public final class ValidationProxyFactory { + + @NotNull + private final ValidationInterceptor interceptor; + + public boolean needsProxy(@NotNull Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(Validate.class)); + } + + @NotNull + @SuppressWarnings("unchecked") + public T wrap(@NotNull T instance) { + final Class[] interfaces = instance.getClass().getInterfaces(); + if (interfaces.length == 0) { + log.fine(() -> "Cannot proxy " + + instance.getClass().getSimpleName() + + " for @Validate — no interfaces."); + return instance; + } + return (T) Proxy.newProxyInstance( + instance.getClass().getClassLoader(), + interfaces, + new ValidationInvocationHandler(instance, interceptor) + ); + } + + private static final class ValidationInvocationHandler + implements InvocationHandler { + + private final Object target; + private final ValidationInterceptor interceptor; + + ValidationInvocationHandler( + @NotNull Object target, + @NotNull ValidationInterceptor interceptor + ) { + this.target = target; + this.interceptor = interceptor; + } + + @Override + public Object invoke( + Object proxy, + Method method, + Object[] args + ) throws Throwable { + final Method targetMethod; + try { + targetMethod = target.getClass() + .getDeclaredMethod(method.getName(), + method.getParameterTypes()); + } catch (NoSuchMethodException ex) { + return method.invoke(target, args); + } + + if (!targetMethod.isAnnotationPresent(Validate.class)) { + return method.invoke(target, args); + } + + return interceptor.intercept(targetMethod, args, () -> { + targetMethod.setAccessible(true); + return targetMethod.invoke(target, args); + }); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/constraints/Max.java b/src/main/java/dev/mzcy/core/validation/constraints/Max.java new file mode 100644 index 0000000..7a5b09b --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/constraints/Max.java @@ -0,0 +1,12 @@ +package dev.mzcy.core.validation.constraints; + +import java.lang.annotation.*; + +/** Numeric parameter must be ≤ value. */ +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Max { + long value(); + String message() default "must be at most {value}"; +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/constraints/Min.java b/src/main/java/dev/mzcy/core/validation/constraints/Min.java new file mode 100644 index 0000000..002ed69 --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/constraints/Min.java @@ -0,0 +1,12 @@ +package dev.mzcy.core.validation.constraints; + +import java.lang.annotation.*; + +/** Numeric parameter must be ≥ value. */ +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Min { + long value(); + String message() default "must be at least {value}"; +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/constraints/NotBlank.java b/src/main/java/dev/mzcy/core/validation/constraints/NotBlank.java new file mode 100644 index 0000000..c323505 --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/constraints/NotBlank.java @@ -0,0 +1,11 @@ +package dev.mzcy.core.validation.constraints; + +import java.lang.annotation.*; + +/** String parameter must not be null or blank. */ +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface NotBlank { + String message() default "must not be blank"; +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/constraints/NotNull.java b/src/main/java/dev/mzcy/core/validation/constraints/NotNull.java new file mode 100644 index 0000000..a5daf77 --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/constraints/NotNull.java @@ -0,0 +1,11 @@ +package dev.mzcy.core.validation.constraints; + +import java.lang.annotation.*; + +/** Parameter must not be null. */ +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface NotNull { + String message() default "must not be null"; +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/constraints/Pattern.java b/src/main/java/dev/mzcy/core/validation/constraints/Pattern.java new file mode 100644 index 0000000..56e2b19 --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/constraints/Pattern.java @@ -0,0 +1,12 @@ +package dev.mzcy.core.validation.constraints; + +import java.lang.annotation.*; + +/** String parameter must match the given regex. */ +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Pattern { + String value(); + String message() default "must match pattern '{value}'"; +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/constraints/Positive.java b/src/main/java/dev/mzcy/core/validation/constraints/Positive.java new file mode 100644 index 0000000..4a3e0c7 --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/constraints/Positive.java @@ -0,0 +1,11 @@ +package dev.mzcy.core.validation.constraints; + +import java.lang.annotation.*; + +/** Numeric parameter must be > 0. */ +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Positive { + String message() default "must be positive"; +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/constraints/Range.java b/src/main/java/dev/mzcy/core/validation/constraints/Range.java new file mode 100644 index 0000000..981eaa6 --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/constraints/Range.java @@ -0,0 +1,13 @@ +package dev.mzcy.core.validation.constraints; + +import java.lang.annotation.*; + +/** Numeric parameter must be between min and max (inclusive). */ +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Range { + long min(); + long max(); + String message() default "must be between {min} and {max}"; +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/constraints/Size.java b/src/main/java/dev/mzcy/core/validation/constraints/Size.java new file mode 100644 index 0000000..a55fc7b --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/constraints/Size.java @@ -0,0 +1,13 @@ +package dev.mzcy.core.validation.constraints; + +import java.lang.annotation.*; + +/** String or Collection parameter must have a size between min and max. */ +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Size { + int min() default 0; + int max() default Integer.MAX_VALUE; + String message() default "size must be between {min} and {max}"; +} \ No newline at end of file diff --git a/src/main/java/dev/mzcy/core/validation/constraints/Validate.java b/src/main/java/dev/mzcy/core/validation/constraints/Validate.java new file mode 100644 index 0000000..c21ab17 --- /dev/null +++ b/src/main/java/dev/mzcy/core/validation/constraints/Validate.java @@ -0,0 +1,32 @@ +package dev.mzcy.core.validation.constraints; + +import java.lang.annotation.*; + +/** + * Enables parameter validation on a method. + * + *

When present, the {@link dev.mzcy.core.validation.ValidationInterceptor} + * checks all annotated parameters before the method executes. + * Throws {@link dev.mzcy.core.validation.ValidationException} on failure. + * + *

Example: + *

{@code
+ * @Validate
+ * public void createHome(
+ *     @NotNull                     Player player,
+ *     @NotBlank @Size(min=1,max=16) String name,
+ *     @NotNull                     Location location
+ * ) { ... }
+ *
+ * @Validate
+ * public void setBalance(
+ *     @NotNull UUID uuid,
+ *     @Min(0) @Max(1_000_000) double amount
+ * ) { ... }
+ * }
+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Validate { +} \ No newline at end of file