diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index 892922e4dec..56cf2a33227 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -221,7 +221,9 @@ public void onRuneScapeProfileChanged(RuneScapeProfileChanged event) { log.info("\nReceived RuneScape profile change event from '{}' to '{}'", oldProfile, newProfile); if (microbotConfig.isRs2CacheEnabled()) { + // Use async profile change to avoid blocking client thread Rs2CacheManager.handleProfileChange(newProfile, oldProfile); + log.info("Initiated async profile change from '{}' to '{}'", oldProfile, newProfile); } return; } @@ -435,7 +437,8 @@ public void onConfigChanged(ConfigChanged ev) } break; case MicrobotConfig.keyEnableCache: - Microbot.showMessage("Restart your client to apply cache changes"); + // Handle dynamic cache system initialization/shutdown + handleCacheConfigChange(ev.getNewValue()); break; default: break; @@ -535,9 +538,17 @@ public void onGameTick(GameTick event) @Subscribe(priority = 100) private void onClientShutdown(ClientShutdown e) { - // Save all caches through Rs2CacheManager + // Save all caches through Rs2CacheManager using async operations if (microbotConfig.isRs2CacheEnabled()) { - Rs2CacheManager.savePersistentCaches(); + try { + // Use async save but wait for completion during shutdown + Rs2CacheManager.savePersistentCachesAsync().get(30, java.util.concurrent.TimeUnit.SECONDS); + log.info("Successfully saved all caches asynchronously during shutdown"); + } catch (Exception ex) { + log.error("Failed to save caches during shutdown: {}", ex.getMessage(), ex); + // Fallback to synchronous save if async fails + Rs2CacheManager.savePersistentCaches(); + } Rs2CacheManager.getInstance().close(); } } @@ -548,12 +559,20 @@ private void onClientShutdown(ClientShutdown e) */ private void initializeCacheSystem() { try { + // Check if already initialized + if (Rs2CacheManager.isEventHandlersRegistered()) { + log.debug("Cache system already initialized, skipping"); + return; + } + // Get the cache manager instance Rs2CacheManager cacheManager = Rs2CacheManager.getInstance(); // Set the EventBus for cache event handling (without loading caches yet) Rs2CacheManager.setEventBus(eventBus); + // Register event handlers + Rs2CacheManager.registerEventHandlers(); // Keep deprecated EntityCache for backward compatibility (for now) //Rs2EntityCache.getInstance(); @@ -572,10 +591,19 @@ private void initializeCacheSystem() { */ private void shutdownCacheSystem() { try { + // Check if already shutdown + if (!Rs2CacheManager.isEventHandlersRegistered()) { + log.debug("Cache system already shutdown, skipping"); + return; + } + Rs2CacheManager cacheManager = Rs2CacheManager.getInstance(); log.debug("Final cache statistics before shutdown: {}", cacheManager.getCacheStatistics()); + // Unregister event handlers first + Rs2CacheManager.unregisterEventHandlers(); + // Close the cache manager and all caches cacheManager.close(); @@ -599,6 +627,26 @@ private void shutdownCacheSystem() { log.error("Error during cache system shutdown: {}", e.getMessage(), e); } } + + /** + * Handles cache configuration changes dynamically without requiring client restart. + * This method is called when the user changes the "Enable Microbot Cache" config option. + * + * @param newValue The new value of the cache enable config ("true" or "false") + */ + private void handleCacheConfigChange(String newValue) { + boolean enableCache = Objects.equals(newValue, "true"); + + if (enableCache) { + log.info("Cache system enabled via config change - initializing..."); + initializeCacheSystem(); + Microbot.showMessage("Cache system enabled successfully"); + } else { + log.info("Cache system disabled via config change - shutting down..."); + shutdownCacheSystem(); + Microbot.showMessage("Cache system disabled successfully"); + } + } /** * Dynamically checks if any visible widget overlaps with the specified bounds * @param overlayBoundsCanvas The bounds to check for widget overlap diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java index cf88dcf6906..9419ef0f02d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java @@ -23,7 +23,7 @@ import net.runelite.client.plugins.microbot.util.poh.PohTeleports; import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; - +import net.runelite.client.plugins.microbot.util.cache.Rs2SkillCache; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; @@ -542,8 +542,14 @@ private boolean hasRequiredLevels(Transport transport) { int[] requiredLevels = transport.getSkillLevels(); Skill[] skills = Skill.values(); return IntStream.range(0, requiredLevels.length) - .filter(i -> requiredLevels[i] > 0) - .allMatch(i -> Microbot.getClient().getBoostedSkillLevel(skills[i]) >= requiredLevels[i]); + .filter(i -> requiredLevels[i] > 0) + .allMatch(i -> { + if (Microbot.isRs2CacheEnabled()) { + return Rs2SkillCache.getBoostedSkillLevel(skills[i]) >= requiredLevels[i]; + } else { + return Microbot.getClient().getBoostedSkillLevel(skills[i]) >= requiredLevels[i]; + } + }); } /** @@ -553,8 +559,14 @@ private boolean hasRequiredLevels(Restriction restriction) { int[] requiredLevels = restriction.getSkillLevels(); Skill[] skills = Skill.values(); return IntStream.range(0, requiredLevels.length) - .filter(i -> requiredLevels[i] > 0) - .allMatch(i -> Microbot.getClient().getBoostedSkillLevel(skills[i]) >= requiredLevels[i]); + .filter(i -> requiredLevels[i] > 0) + .allMatch(i -> { + if (Microbot.isRs2CacheEnabled()) { + return Rs2SkillCache.getBoostedSkillLevel(skills[i]) >= requiredLevels[i]; + } else { + return Microbot.getClient().getBoostedSkillLevel(skills[i]) >= requiredLevels[i]; + } + }); } private void updateActionBasedOnQuestState(Transport transport) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/Rs2Cache.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/Rs2Cache.java index bcccd7923ce..3e90c16ed43 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/Rs2Cache.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/Rs2Cache.java @@ -577,10 +577,11 @@ public synchronized void invalidateAll() { * Gets the cache timestamp for a specific key. * * @param key The key to get the timestamp for - * @return The timestamp when the key was cached, or null if not found + * @return The timestamp when the key was cached, or -1 if not found */ - public Long getCacheTimestamp(K key) { - return cacheTimestamps.get(key); + public long getCacheTimestamp(K key) { + Long timestamp = cacheTimestamps.get(key); + return timestamp != null ? timestamp : 0L; } // ============================================ diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/Rs2CacheManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/Rs2CacheManager.java index 77543ddd497..c4b8c519e40 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/Rs2CacheManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/Rs2CacheManager.java @@ -10,7 +10,9 @@ import net.runelite.client.plugins.microbot.util.cache.serialization.CacheSerializationManager; import net.runelite.client.plugins.microbot.util.cache.util.LogOutputMode; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -28,6 +30,7 @@ public class Rs2CacheManager implements AutoCloseable { private static EventBus eventBus; private final ScheduledExecutorService cleanupExecutor; + private final ExecutorService cacheManagerExecutor; private final AtomicBoolean isShutdown; // Profile management - similar to Rs2Bank @@ -41,6 +44,10 @@ public class Rs2CacheManager implements AutoCloseable { private static final int MAX_CACHE_LOAD_ATTEMPTS = 10; // Configurable max retry attempts private static final long CACHE_LOAD_RETRY_DELAY_MS = 1000; // 1 second between retries private static final AtomicBoolean cacheLoadingInProgress = new AtomicBoolean(false); + + // Async operation tracking + private static final AtomicReference> currentSaveOperation = new AtomicReference<>(); + private static final AtomicReference> currentLoadOperation = new AtomicReference<>(); /** * Checks if cache data is VALID at the manager level (profile consistency). @@ -120,9 +127,14 @@ private Rs2CacheManager() { thread.setDaemon(true); return thread; }); + this.cacheManagerExecutor = Executors.newFixedThreadPool(2, runnable -> { + Thread cacheThread = new Thread(runnable, "Rs2Cache-Persistence"); + cacheThread.setDaemon(true); + return cacheThread; + }); this.isShutdown = new AtomicBoolean(false); - - log.debug("Rs2CacheManager (Unified) initialized"); + + log.debug("Rs2CacheManager (Unified) initialized with async operations support"); } /** @@ -216,6 +228,15 @@ public static void unregisterEventHandlers() { } } + /** + * Checks if cache event handlers are currently registered with the EventBus. + * + * @return true if event handlers are registered, false otherwise + */ + public static boolean isEventHandlersRegistered() { + return isEventRegistered.get(); + } + /** * Invalidates all known unified caches. */ @@ -276,15 +297,14 @@ private static void updateLastKnownPlayerName(String playerName) { } /** - * Gets the last known player name, falling back to current player if available. - * This ensures we can perform character-specific operations even during shutdown. + * Gets the current player name with improved tracking and caching. + * This method provides reliable player name access for cache operations. * - * @return Last known player name or null if never set + * @return Current player name or null if not available */ - public static String getLastKnownPlayerName() { - // try to get current player name first + public static String getCurrentPlayerName() { try { - if (Microbot.isLoggedIn()) { + if (Microbot.isLoggedIn() && Microbot.getClient() != null) { String currentPlayerName = Microbot.getClient().getLocalPlayer() != null ? Microbot.getClient().getLocalPlayer().getName() : null; if (currentPlayerName != null && !currentPlayerName.trim().isEmpty()) { @@ -293,7 +313,23 @@ public static String getLastKnownPlayerName() { } } } catch (Exception e) { - log.debug("Error getting current player name, using last known: {}", e.getMessage()); + log.debug("Error getting current player name: {}", e.getMessage()); + } + + return null; + } + + /** + * Gets the last known player name, falling back to current player if available. + * This ensures we can perform character-specific operations even during shutdown. + * + * @return Last known player name or null if never set + */ + public static String getLastKnownPlayerName() { + // try to get current player name first + String currentName = getCurrentPlayerName(); + if (currentName != null) { + return currentName; } // fallback to last known player name @@ -452,7 +488,36 @@ public void close() { - // Shutdown executor + // Wait for any ongoing cache operations before shutting down + try { + CompletableFuture ongoingSave = currentSaveOperation.get(); + CompletableFuture ongoingLoad = currentLoadOperation.get(); + + if (ongoingSave != null || ongoingLoad != null) { + log.info("Waiting for ongoing cache operations to complete during shutdown"); + if (ongoingSave != null) { + ongoingSave.get(15, TimeUnit.SECONDS); + } + if (ongoingLoad != null) { + ongoingLoad.get(15, TimeUnit.SECONDS); + } + log.info("Cache operations completed during shutdown"); + } + } catch (Exception e) { + log.error("Error waiting for cache operations during shutdown: {}", e.getMessage(), e); + } + + // Shutdown executors + cacheManagerExecutor.shutdown(); + try { + if (!cacheManagerExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + cacheManagerExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + cacheManagerExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + cleanupExecutor.shutdown(); try { if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) { @@ -619,8 +684,7 @@ private static void loadPersistentCaches(String profileKey) { } log.debug ("Loaded SpiritTree cache from configuration, new cache size: {}", Rs2SpiritTreeCache.getCache().size()); - } - + } log.info("Finished Try to loaded all persistent caches from configuration for profile: {} - player {}", profileKey, playerName); } catch (Exception e) { log.error("Failed to load persistent caches from configuration for profile: {}", profileKey, e); @@ -647,16 +711,14 @@ public static void loadCacheStateFromCurrentProfile() { */ public static void loadCacheStateFromConfig(String newRsProfileKey) { if (!isCacheDataValid()) { - // Start retry task if not already in progress - if (cacheLoadingInProgress.compareAndSet(false, true)) { - // Schedule retry task in background thread - getInstance().cleanupExecutor.schedule(() -> { - retryLoadCacheWithValidation(newRsProfileKey, 0); - }, 0, TimeUnit.MILLISECONDS); - log.info("Starting cache loading with player validation for profile: {}", newRsProfileKey); - } else { - log.debug("Cache loading already in progress, skipping duplicate request"); - } + // Use async loading to avoid blocking client thread + loadCachesAsync(newRsProfileKey).whenComplete((result, ex) -> { + if (ex != null) { + log.error("Failed to load cache state async for profile: {}", newRsProfileKey, ex); + } else { + log.info("Successfully loaded cache state async for profile: {}", newRsProfileKey); + } + }); } } @@ -722,7 +784,46 @@ public static void setUnknownInitialCacheState() { } /** - * Loads cache state from config, handling profile changes. + * Loads cache state asynchronously without blocking the client thread. + * Returns immediately and performs load operations in background threads. + * + * @param newRsProfileKey The profile key to load cache for + * @return CompletableFuture that completes when load is done + */ + public static CompletableFuture loadCachesAsync(String newRsProfileKey) { + // Ensure only one load operation at a time + if (cacheLoadingInProgress.compareAndSet(false, true)) { + CompletableFuture loadOperation = CompletableFuture.runAsync(() -> { + try { + retryLoadCacheWithValidation(newRsProfileKey, 0); + } catch (Exception e) { + log.error("Failed during async cache loading for profile: {}", newRsProfileKey, e); + cacheLoadingInProgress.set(false); // reset flag on unexpected error + throw new RuntimeException("Async cache load failed", e); + } + }, getInstance().cacheManagerExecutor); + + // Track the current operation + currentLoadOperation.set(loadOperation); + + return loadOperation.whenComplete((result, ex) -> { + // Clear the operation reference when done + currentLoadOperation.compareAndSet(loadOperation, null); + if (ex != null) { + log.error("Async cache loading failed for profile: {}", newRsProfileKey, ex); + } else { + log.info("Async cache loading completed for profile: {}", newRsProfileKey); + } + }); + } else { + log.debug("Cache loading already in progress, returning existing operation"); + CompletableFuture existingOperation = currentLoadOperation.get(); + return existingOperation != null ? existingOperation : CompletableFuture.completedFuture(null); + } + } + + /** + * Loads cache state from config, handling profile changes. * This method handles both Rs2Bank and other cache systems. */ private static void loadCaches(String newRsProfileKey) { @@ -747,13 +848,24 @@ private static void loadCaches(String newRsProfileKey) { * Similar to Rs2Bank.handleProfileChange(). */ public static void handleProfileChange(String newRsProfileKey, String prvProfile) { - // Save current cache state before loading new profile - savePersistentCaches(prvProfile); - setUnknownInitialCacheState(); - // Load cache state for new profile - loadCacheStateFromConfig(newRsProfileKey); - // Update spirit tree cache with current farming handler data after profile change - + log.info("Handling profile change from '{}' to '{}' with async operations", prvProfile, newRsProfileKey); + + // Save current cache state before loading new profile (async) + CompletableFuture saveOperation = savePersistentCachesAsync(prvProfile); + + // Chain the profile switch operations + saveOperation.thenRunAsync(() -> { + setUnknownInitialCacheState(); + // Load cache state for new profile (this will use async internally) + loadCacheStateFromConfig(newRsProfileKey); + }, getInstance().cacheManagerExecutor) + .whenComplete((result, ex) -> { + if (ex != null) { + log.error("Failed to complete async profile change from '{}' to '{}'", prvProfile, newRsProfileKey, ex); + } else { + log.info("Successfully completed async profile change from '{}' to '{}'", prvProfile, newRsProfileKey); + } + }); } /** @@ -768,6 +880,7 @@ public static void emptyCacheState() { long cacheTimestamp = Rs2VarPlayerCache.getInstance().getCacheTimestamp(VarPlayerID.ACCOUNT_CREDIT); if (cacheTimestamp <= 0) { log.info("No valid cache timestamp found for membership validation"); + cacheTimestamp = 0L; } // calculate days since cache was saved @@ -792,6 +905,85 @@ public static void emptyCacheState() { log.info("Emptied all cache states"); } + /** + * Saves all persistent caches asynchronously without blocking the client thread. + * Returns immediately and performs save operations in background threads. + * + * @return CompletableFuture that completes when all saves are done + */ + public static CompletableFuture savePersistentCachesAsync() { + try { + if (rsProfileKey != null && !rsProfileKey.get().isEmpty()) { + String playerName = getLastKnownPlayerName(); + if (playerName == null) { + log.warn("Cannot save persistent caches - no player name available"); + return CompletableFuture.completedFuture(null); + } + return savePersistentCachesAsync(rsProfileKey.get()); + } + return CompletableFuture.completedFuture(null); + } catch (Exception e) { + log.error("Failed to start async save of persistent caches", e); + return CompletableFuture.failedFuture(e); + } + } + + /** + * Saves all persistent caches asynchronously for a specific profile. + * + * @param profileKey The RuneLite profile key to save caches for + * @return CompletableFuture that completes when all saves are done + */ + public static CompletableFuture savePersistentCachesAsync(String profileKey) { + if (profileKey == null) { + log.warn("Cannot save persistent caches: profile key is null"); + return CompletableFuture.completedFuture(null); + } + + // if we're saving a "previous" profile during a switch, proceed even if ConfigManager has + // already moved to the new profile. otherwise ensure current state is valid. + if (profileKey.equals(rsProfileKey.get())) { + if (!isCacheDataValid()) { + log.warn("Cache data is not valid for profile '{}', cannot save persistent caches", profileKey); + return CompletableFuture.completedFuture(null); + } + } else { + log.debug("Saving caches for previous profile '{}' (active='{}')", profileKey, rsProfileKey.get()); + } + + String playerName = getLastKnownPlayerName(); + if (playerName == null) { + log.warn("Cannot save persistent caches - no player name available"); + return CompletableFuture.completedFuture(null); + } + + log.info("Starting async save of all persistent caches for profile: {}", profileKey); + + // Ensure only one save operation at a time + CompletableFuture saveOperation = CompletableFuture.runAsync(() -> { + try { + // Save Rs2Bank cache first + Rs2Bank.saveCacheToConfig(profileKey); + + // Save other persistent caches + savePersistentCachesInternal(profileKey); + + log.info("Successfully completed async save of all persistent caches for profile: {}", profileKey); + } catch (Exception e) { + log.error("Failed during async save of persistent caches for profile: {}", profileKey, e); + throw new RuntimeException("Async cache save failed", e); + } + }, getInstance().cacheManagerExecutor); + + // Track the current operation + currentSaveOperation.set(saveOperation); + + return saveOperation.whenComplete((result, ex) -> { + // Clear the operation reference when done + currentSaveOperation.compareAndSet(saveOperation, null); + }); + } + /** * Saves all persistent caches to RuneLite profile configuration. * This method handles both Rs2Bank and other cache systems. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/serialization/CacheSerializationManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/serialization/CacheSerializationManager.java index 14fe0676802..ca429ea449e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/serialization/CacheSerializationManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/cache/serialization/CacheSerializationManager.java @@ -20,18 +20,25 @@ import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; import java.util.Map; import java.util.UUID; /** * Serialization manager for Rs2UnifiedCache instances. - * Handles automatic save/load to RuneLite profile configuration - * similar to Rs2Bank serialization system. - * + * Handles automatic save/load to file-based storage under .runelite/microbot-profiles + * to prevent RuneLite profile bloat and improve performance. + * * Includes cache freshness tracking to prevent loading stale cache data * that wasn't properly saved due to ungraceful client shutdowns. - * + * * Cache freshness is determined by whether data was saved after being loaded, * not by session ID or time limits (unless explicitly specified). * This ensures we only load cache data that was properly persisted after modifications. @@ -39,38 +46,54 @@ @Slf4j public class CacheSerializationManager { private static final String VERSION = "1.0.0"; // Version for cache serialization format compatibility - private static final String CONFIG_GROUP = "microbot"; - private static final String METADATA_SUFFIX = "_metadata"; + private static final String BASE_DIRECTORY = ".runelite/microbot-profiles"; + private static final String CACHE_SUBDIRECTORY = "caches"; + private static final String METADATA_SUFFIX = ".metadata"; + private static final String JSON_EXTENSION = ".json"; + private static final Gson gson; // Session identifier to track cache freshness across client restarts private static final String SESSION_ID = UUID.randomUUID().toString(); /** - * Metadata class to track cache freshness and validity + * Enhanced metadata class to track cache freshness, validity, and integrity. + * Inspired by GLite's PersistenceMetadata with data size tracking and cache naming. */ private static class CacheMetadata { private final String version; private final String sessionId; private final String saveTimestampUtc; // UTC timestamp in ISO 8601 format private final boolean stale; + private final int dataSize; // Size of serialized data for integrity checks + private final String cacheName; // Name of cache for debugging // UTC formatter for consistent timestamp handling private static final DateTimeFormatter UTC_FORMATTER = DateTimeFormatter.ISO_INSTANT; - public CacheMetadata(String version, String sessionId, String saveTimestampUtc, boolean stale) { + public CacheMetadata(String version, String sessionId, String saveTimestampUtc, boolean stale, int dataSize, String cacheName) { this.version = version; this.sessionId = sessionId; this.saveTimestampUtc = saveTimestampUtc; this.stale = stale; + this.dataSize = dataSize; + this.cacheName = cacheName; } /** * Create CacheMetadata with current UTC timestamp */ + public static CacheMetadata createWithCurrentUtcTime(String version, String sessionId, boolean stale, int dataSize, String cacheName) { + String utcTimestamp = Instant.now().atOffset(ZoneOffset.UTC).format(UTC_FORMATTER); + return new CacheMetadata(version, sessionId, utcTimestamp, stale, dataSize, cacheName); + } + + /** + * Create CacheMetadata with current UTC timestamp and convenience method for common use + */ public static CacheMetadata createWithCurrentUtcTime(String version, String sessionId, boolean stale) { String utcTimestamp = Instant.now().atOffset(ZoneOffset.UTC).format(UTC_FORMATTER); - return new CacheMetadata(version, sessionId, utcTimestamp, stale); + return new CacheMetadata(version, sessionId, utcTimestamp, stale, 0, "unknown"); } public boolean isNewVersion(String currentVersion){ @@ -131,7 +154,70 @@ public String getSaveTimeFormatted() { public boolean isStale() { return stale; - } + } + + /** + * Get the data size for integrity checking + */ + public int getDataSize() { + return dataSize; + } + + /** + * Get the cache name for debugging + */ + public String getCacheName() { + return cacheName; + } + + /** + * Validates that this metadata has reasonable values + */ + public void validate() throws IllegalStateException { + if (version == null || version.trim().isEmpty()) { + throw new IllegalStateException("Version cannot be null or empty"); + } + if (dataSize < 0) { + throw new IllegalStateException("Data size cannot be negative"); + } + if (saveTimestampUtc == null || saveTimestampUtc.trim().isEmpty()) { + throw new IllegalStateException("Save timestamp cannot be null or empty"); + } + } + + /** + * Gets a human-readable age description + */ + public String getFormattedAge() { + long ageMs = getAgeMs(); + + if (ageMs < 1000) { + return ageMs + "ms ago"; + } else if (ageMs < 60_000) { + return (ageMs / 1000) + "s ago"; + } else if (ageMs < 3_600_000) { + return (ageMs / 60_000) + "m ago"; + } else if (ageMs < 86_400_000) { + return (ageMs / 3_600_000) + "h ago"; + } else { + return (ageMs / 86_400_000) + "d ago"; + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("CacheMetadata{"); + sb.append("version='").append(version).append("'"); + sb.append(", cacheName='").append(cacheName).append("'"); + sb.append(", timestamp=").append(getSaveTimeFormatted()); + sb.append(" (").append(getFormattedAge()).append(")"); + sb.append(", dataSize=").append(dataSize); + sb.append(", stale=").append(stale); + sb.append(", sessionId='").append(sessionId).append("'"); + sb.append("}"); + return sb.toString(); + } } // Initialize Gson with custom adapters @@ -148,11 +234,11 @@ public boolean isStale() { } /** - * Saves a cache to RuneLite profile configuration with character-specific keys. + * Saves a cache to file-based storage with character-specific directory structure. * Also stores metadata to track cache freshness and prevent loading stale data. * * @param cache The cache to save - * @param configKey The base config key to save under + * @param configKey The cache type identifier (skills, quests, etc.) * @param rsProfileKey The RuneLite profile key * @param playerName The player name for character-specific caching * @param The key type @@ -160,8 +246,8 @@ public boolean isStale() { */ public static void saveCache(Rs2Cache cache, String configKey, String rsProfileKey, String playerName) { try { - if (rsProfileKey == null || Microbot.getConfigManager() == null) { - log.warn("Cannot save cache {}: profile key or config manager not available", configKey); + if (rsProfileKey == null) { + log.warn("Cannot save cache {}: profile key not available", configKey); return; } @@ -170,29 +256,34 @@ public static void saveCache(Rs2Cache cache, String configKey, Stri return; } - // create character-specific config key - String characterConfigKey = createCharacterSpecificKey(configKey, playerName); + // create directory structure + Path cacheDir = getCacheDirectory(rsProfileKey, playerName); + Files.createDirectories(cacheDir); - // Serialize the cache data + // serialize cache data String json = serializeCacheData(cache, configKey); - - if (json != null && !json.isEmpty()) { - log.debug("{} JSON length: {} for player: {}", configKey, json.length(), playerName); - Microbot.getConfigManager().setConfiguration(CONFIG_GROUP, rsProfileKey, characterConfigKey, json); - - // Store metadata to track cache freshness - // Mark as stale=false since we're actively saving cache data - CacheMetadata metadata = CacheMetadata.createWithCurrentUtcTime(VERSION, SESSION_ID, false); - String metadataJson = gson.toJson(metadata, CacheMetadata.class); - String metadataKey = characterConfigKey + METADATA_SUFFIX; - Microbot.getConfigManager().setConfiguration(CONFIG_GROUP, rsProfileKey, metadataKey, metadataJson); - log.info("Saved cache \"{}\" for player \"{}\" with updated metadata for session {} at {}", configKey, playerName, SESSION_ID, metadata.getSaveTimeFormatted()); - } else { + if (json == null || json.trim().isEmpty()) { log.warn("No data to save for cache {} for player {}", configKey, playerName); + return; } + // save cache data file + Path cacheFile = cacheDir.resolve(configKey + JSON_EXTENSION); + Files.write(cacheFile, json.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + + // create and save metadata with data size and cache name + CacheMetadata metadata = CacheMetadata.createWithCurrentUtcTime(VERSION, SESSION_ID, false, json.length(), configKey); + String metadataJson = gson.toJson(metadata, CacheMetadata.class); + Path metadataFile = cacheDir.resolve(configKey + METADATA_SUFFIX); + Files.write(metadataFile, metadataJson.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + + log.info("Saved cache \"{}\" for player \"{}\" to file ({} chars) at {}", + configKey, playerName, json.length(), metadata.getSaveTimeFormatted()); + + } catch (IOException e) { + log.error("Failed to save cache {} to file for player {}", configKey, playerName, e); } catch (Exception e) { - log.error("Failed to save cache {} to config", configKey, e); + log.error("Failed to save cache {} for player {}", configKey, playerName, e); } } @@ -217,25 +308,13 @@ private static String getCurrentPlayerName() { } } - /** - * Creates a character-specific config key by appending player name. - * - * @param baseKey The base config key - * @param playerName The player name - * @return Character-specific config key - */ - public static String createCharacterSpecificKey(String baseKey, String playerName) { - // sanitize player name for config key usage - String sanitizedPlayerName = playerName.replaceAll("[^a-zA-Z0-9_-]", "_"); - return baseKey + "_" + sanitizedPlayerName; - } /** - * Loads a cache from RuneLite profile configuration with character-specific keys. + * Loads a cache from file-based storage with character-specific directory structure. * Checks cache freshness metadata before loading to prevent loading stale data. * * @param cache The cache to load into - * @param configKey The base config key to load from + * @param configKey The cache type identifier (skills, quests, etc.) * @param rsProfileKey The profile key to load from * @param playerName The player name for character-specific caching * @param forceInvalidate Whether to force cache invalidation @@ -248,20 +327,22 @@ public static void loadCache(Rs2Cache cache, String configKey, Stri /** - * Loads a cache from RuneLite profile configuration with age limit. + * Loads a cache from file-based storage with age limit. * Checks cache freshness metadata before loading to prevent loading stale data. - * + * * @param cache The cache to load into - * @param configKey The config key to load from + * @param configKey The cache type identifier * @param rsProfileKey The profile key to load from + * @param playerName The player name for character-specific caching * @param maxAgeMs Maximum age in milliseconds (0 = ignore time completely) + * @param forceInvalidate Whether to force cache invalidation * @param The key type * @param The value type */ public static void loadCache(Rs2Cache cache, String configKey, String rsProfileKey, String playerName, long maxAgeMs, boolean forceInvalidate) { try { - if (Microbot.getConfigManager() == null) { - log.warn("Cannot load cache {}: config manager not available", configKey); + if (rsProfileKey == null) { + log.warn("Cannot load cache {}: profile key not available", configKey); return; } @@ -270,126 +351,142 @@ public static void loadCache(Rs2Cache cache, String configKey, Stri return; } - // create character-specific config key - String characterConfigKey = createCharacterSpecificKey(configKey, playerName); + Path cacheDir = getCacheDirectory(rsProfileKey, playerName); + Path metadataFile = cacheDir.resolve(configKey + METADATA_SUFFIX); + Path cacheFile = cacheDir.resolve(configKey + JSON_EXTENSION); - // Check cache freshness metadata first - String metadataKey = characterConfigKey + METADATA_SUFFIX; - String metadataJson = Microbot.getConfigManager().getConfiguration(CONFIG_GROUP, rsProfileKey, metadataKey); - - CacheMetadata metadata = null; - boolean shouldLoadFromConfig = false; - - if (metadataJson != null && !metadataJson.isEmpty()) { - try { - metadata = gson.fromJson(metadataJson, CacheMetadata.class); - if (metadata != null){ - long age = metadata.getAgeMs(); - boolean stale = metadata.isStale(); - boolean fromCurrentSession = metadata.isFromCurrentSession(); - String oldVersion = metadata.version; - boolean useLoadCacheData = !stale && metadata.isFresh(maxAgeMs) && !metadata.isNewVersion(VERSION); - if (useLoadCacheData) { - shouldLoadFromConfig = true; - log.info("\nCache \"{}\" for player \"{}\" has valid metadata, loading from config \n" + // - " -stale: {}\n" + // - " -age: {}ms -isfresh? {} -max Age {}ms\n" + // - " -from current session: {}\n" + // - " -current version {} \n-last version {}\n-is new version? {}", - configKey, playerName, stale, age, metadata.isFresh(maxAgeMs), maxAgeMs, fromCurrentSession, VERSION, oldVersion, metadata.isNewVersion(VERSION)); - } else { - log.warn("\nCache \"{}\" for player \"{}\" metadata indicated using fresh cache \n" + // - " -stale: {}\n" + // - " -age: {}ms -isfresh? {} -max {} ms\n" + // - " -from current session: {}\n" + // - " -current version {} - last version {}- is new version? {}", - configKey, playerName, stale, age, metadata.isFresh(maxAgeMs), maxAgeMs, fromCurrentSession, VERSION, oldVersion, metadata.isNewVersion(VERSION)); - } - } - } catch (JsonSyntaxException e) { - log.warn("Failed to parse cache metadata for {} player {}, treating as stale", configKey, playerName, e); - } - } else { - log.warn("No cache metadata found for {} player {}, treating as stale data", configKey, playerName); + // check if files exist + if (!Files.exists(metadataFile) || !Files.exists(cacheFile)) { + log.debug("No cache files found for {} player {}, starting fresh", configKey, playerName); + if (forceInvalidate) cache.invalidateAll(); + + // create initial stale metadata to track first load + CacheMetadata loadedMetadata = CacheMetadata.createWithCurrentUtcTime(VERSION, SESSION_ID, true, 0, configKey); + String metadataJson = gson.toJson(loadedMetadata, CacheMetadata.class); + Files.createDirectories(cacheDir); + Files.write(metadataFile, metadataJson.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + return; } - - if (!shouldLoadFromConfig) { - // Invalidate cache and start fresh instead of loading potentially stale data - if (forceInvalidate) cache.invalidateAll(); - }else{ - // Proceed with loading since metadata indicates fresh data - String json = Microbot.getConfigManager().getConfiguration(CONFIG_GROUP, rsProfileKey, characterConfigKey); - if (json != null && !json.isEmpty()) { - deserializeCacheData(cache, configKey, json); - log.debug("Loaded cache {} for player {} from profile config, entries loaded: {}", configKey, playerName, cache.size()); - } else { - log.warn("No cached data found for {} player {} despite fresh metadata", configKey, playerName); - } + + // read and validate metadata + String metadataJson = Files.readString(metadataFile); + CacheMetadata metadata = gson.fromJson(metadataJson, CacheMetadata.class); + + if (metadata == null || !metadata.isFresh(maxAgeMs)) { + log.warn("Cache \"{}\" for player \"{}\" metadata indicates stale data (age: {}ms, fresh: {})", + configKey, playerName, metadata != null ? metadata.getAgeMs() : "unknown", + metadata != null ? metadata.isFresh(maxAgeMs) : false); + + if (forceInvalidate) cache.invalidateAll(); + return; } - // Mark metadata as loaded but not yet saved to distinguish from fresh saves - CacheMetadata loadedMetadata = CacheMetadata.createWithCurrentUtcTime(VERSION, SESSION_ID, true); - String loadedMetadataJson = gson.toJson(loadedMetadata,CacheMetadata.class); - Microbot.getConfigManager().setConfiguration(CONFIG_GROUP, rsProfileKey, metadataKey, loadedMetadataJson); - Microbot.getConfigManager().sendConfig(); // must be called to ensure config changes are saved immediately to the cloud and/or disk + + // load cache data + String json = Files.readString(cacheFile); + if (json != null && !json.trim().isEmpty()) { + deserializeCacheData(cache, configKey, json); + log.debug("Loaded cache {} for player {} from file, entries loaded: {}", configKey, playerName, cache.size()); + } else { + log.warn("Cache file exists but contains no data for {} player {}", configKey, playerName); + } + + // mark as loaded but stale until next save + CacheMetadata loadedMetadata = CacheMetadata.createWithCurrentUtcTime(VERSION, SESSION_ID, true, json.length(), configKey); + String updatedMetadataJson = gson.toJson(loadedMetadata, CacheMetadata.class); + Files.write(metadataFile, updatedMetadataJson.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } catch (JsonSyntaxException e) { - log.warn("Failed to parse cached data for {} player {}, clearing corrupted cache and starting fresh", configKey, playerName, e); - // Clear the corrupted cache data - clearCache(configKey, rsProfileKey); + log.warn("Failed to parse cache data for {} player {}, clearing corrupted cache", configKey, playerName, e); + clearCacheFiles(configKey, rsProfileKey, playerName); + } catch (IOException e) { + log.error("Failed to load cache {} for player {} from file", configKey, playerName, e); } catch (Exception e) { - log.error("Failed to load cache {} for player {} from config", configKey, playerName, e); + log.error("Failed to load cache {} for player {}", configKey, playerName, e); } } /** - * Clears cache data from profile configuration. - * - * @param configKey The config key to clear + * Clears cache files for current player and profile. + * + * @param configKey The cache type to clear */ public static void clearCache(String configKey) { try { - String rsProfileKey = Microbot.getConfigManager().getRSProfileKey(); - clearCache(configKey, rsProfileKey); + String rsProfileKey = Microbot.getConfigManager() != null ? Microbot.getConfigManager().getRSProfileKey() : null; + String playerName = getCurrentPlayerName(); + clearCacheFiles(configKey, rsProfileKey, playerName); } catch (Exception e) { - log.error("Failed to clear cache {} from config", configKey, e); + log.error("Failed to clear cache {} files", configKey, e); } } /** - * Clears cache data from profile configuration for a specific profile. - * Also clears associated metadata. - * - * @param configKey The config key to clear - * @param rsProfileKey The profile key to clear from + * Clears cache files for a specific profile and player. + * + * @param configKey The cache type to clear + * @param rsProfileKey The profile key */ public static void clearCache(String configKey, String rsProfileKey) { + String playerName = getCurrentPlayerName(); + clearCacheFiles(configKey, rsProfileKey, playerName); + } + + /** + * Clears cache files for specific cache type, profile, and player. + * + * @param configKey The cache type to clear + * @param rsProfileKey The profile key + * @param playerName The player name + */ + public static void clearCacheFiles(String configKey, String rsProfileKey, String playerName) { try { - if (rsProfileKey != null && Microbot.getConfigManager() != null) { - // get player name for character-specific caching - String playerName = getCurrentPlayerName(); - if (playerName != null && !playerName.trim().isEmpty()) { - // create character-specific config key - String characterConfigKey = createCharacterSpecificKey(configKey, playerName); - - // Clear the character-specific cache data - Microbot.getConfigManager().setConfiguration(CONFIG_GROUP, rsProfileKey, characterConfigKey, null); - // Clear the character-specific metadata - String metadataKey = characterConfigKey + METADATA_SUFFIX; - Microbot.getConfigManager().setConfiguration(CONFIG_GROUP, rsProfileKey, metadataKey, null); - log.debug("Cleared cache {} and metadata for player {} from profile config for profile: {}", configKey, playerName, rsProfileKey); - } else { - // fallback to old method if player name not available - Microbot.getConfigManager().setConfiguration(CONFIG_GROUP, rsProfileKey, configKey, null); - String metadataKey = configKey + METADATA_SUFFIX; - Microbot.getConfigManager().setConfiguration(CONFIG_GROUP, rsProfileKey, metadataKey, null); - log.debug("Cleared cache {} and metadata from profile config for profile: {} (no player name)", configKey, rsProfileKey); - } + if (rsProfileKey == null || playerName == null || playerName.trim().isEmpty()) { + log.warn("Cannot clear cache files: profile key or player name not available"); + return; } + + Path cacheDir = getCacheDirectory(rsProfileKey, playerName); + Path cacheFile = cacheDir.resolve(configKey + JSON_EXTENSION); + Path metadataFile = cacheDir.resolve(configKey + METADATA_SUFFIX); + + Files.deleteIfExists(cacheFile); + Files.deleteIfExists(metadataFile); + + log.debug("Cleared cache files for {} player {}", configKey, playerName); + } catch (IOException e) { + log.error("Failed to clear cache files for {} player {}", configKey, playerName, e); + } + } + + /** + * Gets the cache directory path for a profile and player using URL encoding for safety. + * This ensures that different profiles/players don't collide into the same directory. + */ + private static Path getCacheDirectory(String profileKey, String playerName) { + try { + // use URL encoding to safely handle special characters while preserving uniqueness + String encodedPlayerName = URLEncoder.encode(playerName, StandardCharsets.UTF_8); + String encodedProfileKey = URLEncoder.encode(profileKey, StandardCharsets.UTF_8); + + Path userHome = Paths.get(System.getProperty("user.home")); + return userHome.resolve(BASE_DIRECTORY) + .resolve(encodedProfileKey) + .resolve(encodedPlayerName) + .resolve(CACHE_SUBDIRECTORY); } catch (Exception e) { - log.error("Failed to clear cache {} from config for profile: {}", configKey, rsProfileKey, e); + log.error("Failed to encode path components, falling back to basic sanitization", e); + // fallback to basic sanitization if URL encoding fails + String sanitizedPlayerName = playerName.replaceAll("[^a-zA-Z0-9_-]", "_"); + String sanitizedProfileKey = profileKey.replaceAll("[^a-zA-Z0-9_-]", "_"); + + Path userHome = Paths.get(System.getProperty("user.home")); + return userHome.resolve(BASE_DIRECTORY) + .resolve(sanitizedProfileKey) + .resolve(sanitizedPlayerName) + .resolve(CACHE_SUBDIRECTORY); } } - + /** * Serializes cache data to JSON based on cache type. * This method handles different cache types with specific serialization strategies. @@ -397,7 +494,7 @@ public static void clearCache(String configKey, String rsProfileKey) { * NPC cache is excluded as it's dynamically loaded based on game scene. */ @SuppressWarnings("unchecked") - private static String serializeCacheData(Rs2Cache cache, String configKey) { + public static String serializeCacheData(Rs2Cache cache, String configKey) { try { log.debug("Starting serialization for cache type: {}", configKey); @@ -439,7 +536,7 @@ private static String serializeCacheData(Rs2Cache cache, String con * NPC cache is excluded as it's dynamically loaded based on game scene. */ @SuppressWarnings("unchecked") - private static void deserializeCacheData(Rs2Cache cache, String configKey, String json) { + public static void deserializeCacheData(Rs2Cache cache, String configKey, String json) { try { log.debug("Starting deserialization for cache type: {}, JSON length: {}", configKey, json != null ? json.length() : 0); @@ -643,4 +740,18 @@ private static void deserializeSpiritTreeCache(Rs2Cache embeds, List files) { // Retrieve and validate the Discord Webhook URL String webHookUrl = Optional.ofNullable(getDiscordWebhookUrl()) + .map(String::trim) .filter(url -> !url.isEmpty()) + .map(url -> { + // Auto-fix URL if it doesn't have a scheme + if (!url.startsWith("http://") && !url.startsWith("https://")) { + return "https://" + url; + } + return url; + }) .orElseGet(() -> { Microbot.log("The webhook URL is not configured in the RuneLite profile. Please check the configuration."); return null; @@ -69,19 +77,27 @@ public static boolean sendWebhookMessage(String bodyMessage, List RequestBody requestBody = builder.build(); - // Build and execute the request - Request request = new Request.Builder() - .url(webHookUrl) - .post(requestBody) - .build(); + try { + // Build and execute the request + Request request = new Request.Builder() + .url(webHookUrl) + .post(requestBody) + .build(); - try (Response response = httpClient.newCall(request).execute()) { - if (!response.isSuccessful()) { - Microbot.log("Failed to send Discord notification. Error Code: " + response.code()); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + Microbot.log("Failed to send Discord notification. Error Code: " + response.code() + + " - URL marked as invalid: " + webHookUrl); + } + return response.isSuccessful(); } - return response.isSuccessful(); + } catch (IllegalArgumentException e) { + Microbot.log("Invalid Discord webhook URL format - URL marked as invalid: " + webHookUrl + + " - Error: " + e.getMessage()); + return false; } catch (IOException e) { - Microbot.log("Error while sending Discord notification: " + e.getMessage()); + Microbot.log("Error while sending Discord notification to URL: " + webHookUrl + + " - Error: " + e.getMessage()); return false; } }