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 MapChain {@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 extends AbstractConfig> 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 extends AbstractConfig> 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: + *
Before migrating, the original file is backed up as
+ * {@code Usage:
+ * 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 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:
+ * Rules:
+ * 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 =
- " 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()
+ ? " 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 Stored in {@code plugins/Core/data/cooldowns/ Key format: {@code " Auto-discovered and initialized by {@link dev.mzcy.core.data.DataStoreManager}.
+ */
+@DataStore(value = "cooldowns", directory = "data")
+public final class PersistentCooldownStore
+ extends AbstractDataStore Provides filtered views and summary logging.
+ */
+@Log
+@Getter
+public final class DependencyCheckResultSet {
+
+ @NotNull private final String pluginName;
+ @NotNull private final List Integrated into {@link dev.mzcy.core.CorePlugin} to run during boot,
+ * but can also be used standalone by any plugin:
+ *
+ * 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 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 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.
+ *
+ * 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:
+ * Called automatically during DI resolution.
+ *
+ * @param instance the component to potentially wrap
+ * @param Example:
+ * Example:
+ * 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 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:
+ * 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 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:
+ * 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(" Provides:
+ * 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 extends java.lang.annotation.Annotation> 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 " Buckets are created lazily and keyed by
+ * {@code "ClassName.methodName:callerUuid"} or
+ * {@code "ClassName.methodName:__global__"}.
+ */
+public final class RateLimitRegistry {
+
+ private final ConcurrentHashMap 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.
+ *
+ * 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:
+ * 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 extends Throwable>[] types
+ ) {
+ if (types.length == 0) return false;
+ return Arrays.stream(types)
+ .anyMatch(type -> type.isInstance(ex));
+ }
+
+ @SuppressWarnings("unchecked")
+ private Provides:
+ * 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 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 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 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 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 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 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
+ * 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{@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
+ * }
+ * }
+ *
+ *
+ *
+ */
+@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.
*
- * {@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);
+ * }
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+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.
+ *
+ *
+ *
+ */
+@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.
+ *
+ * {@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.
+ *
+ * {@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 {@code
+ * @Component
+ * public class LeaderboardService implements LeaderboardPort {
+ *
+ * @Timed("leaderboard.rebuild")
+ * @Override
+ * public void rebuild() {
+ * // expensive operation
+ * }
+ *
+ * @Timed // uses ClassName.methodName as key
+ * public List
+ *
+ * {@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 = "
+ */
+@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
+ *
+ */
+@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
+ *
+ */
+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.
+ *
+ * {@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 extends Throwable>[] 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 extends Throwable>[] 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.
+ *
+ *
+ *
+ */
+@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 {@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