diff --git a/AbsorbentSoil/AbsorbentSoilMod.cs b/AbsorbentSoil/AbsorbentSoilMod.cs index b880cf6..0c06dcd 100644 --- a/AbsorbentSoil/AbsorbentSoilMod.cs +++ b/AbsorbentSoil/AbsorbentSoilMod.cs @@ -1,16 +1,5 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using HarmonyLib; using MelonLoader; -using Il2CppInterop.Runtime; -using Il2CppScheduleOne.Growing; -using Il2CppScheduleOne.ItemFramework; -using Il2CppScheduleOne.ObjectScripts; -using Il2CppScheduleOne.PlayerTasks.Tasks; -using Il2CppScheduleOne.ObjectScripts.Soil; -using UnityEngine; [assembly: MelonInfo(typeof(AbsorbentSoil.AbsorbentSoilMod), "Absorbent Soil", "0.1.0", "AttilaG")] [assembly: MelonGame(null, "Schedule I")] @@ -23,6 +12,7 @@ namespace AbsorbentSoil public override void OnInitializeMelon() { + PersistenceStore.EnsureLoaded(); MelonLogger.Msg("Absorbent Soil loaded. Additive retention is active for long-life soils."); } @@ -36,355 +26,10 @@ namespace AbsorbentSoil sceneName.IndexOf("load", StringComparison.OrdinalIgnoreCase) >= 0)) { AdditiveMemory.Clear(); - MelonLogger.Msg($"Cleared additive memory on scene load: {sceneName}"); + SoilHelper.Clear(); + PersistenceStore.ReloadNow(); + MelonLogger.Msg($"Reloaded persisted memory on scene load: {sceneName}"); } } } - - internal static class AdditiveMemory - { - private static readonly Dictionary> AdditivesByPotKey = new(); - - public static void Remember(Pot pot, string additiveId) - { - if (pot == null || string.IsNullOrWhiteSpace(additiveId)) - return; - - string key = PotKeyHelper.GetPotKey(pot); - if (string.IsNullOrWhiteSpace(key)) - return; - - if (!AdditivesByPotKey.TryGetValue(key, out HashSet set)) - { - set = new HashSet(StringComparer.OrdinalIgnoreCase); - AdditivesByPotKey[key] = set; - } - - if (set.Add(additiveId) && AbsorbentSoilMod.VerboseLogging) - MelonLogger.Msg($"Remembered additive '{additiveId}' for pot '{key}'."); - } - - public static IReadOnlyList Get(Pot pot) - { - if (pot == null) - return Array.Empty(); - - string key = PotKeyHelper.GetPotKey(pot); - if (string.IsNullOrWhiteSpace(key)) - return Array.Empty(); - - return AdditivesByPotKey.TryGetValue(key, out HashSet set) - ? set.ToArray() - : Array.Empty(); - } - - public static bool HasStored(Pot pot) - { - if (pot == null) - return false; - - string key = PotKeyHelper.GetPotKey(pot); - return !string.IsNullOrWhiteSpace(key) && - AdditivesByPotKey.TryGetValue(key, out HashSet set) && - set.Count > 0; - } - - public static void Forget(Pot pot) - { - if (pot == null) - return; - - string key = PotKeyHelper.GetPotKey(pot); - if (!string.IsNullOrWhiteSpace(key)) - AdditivesByPotKey.Remove(key); - } - - public static void Clear() => AdditivesByPotKey.Clear(); - } - - internal static class PotKeyHelper - { - public static string GetPotKey(Pot pot) - { - if (pot == null) - return string.Empty; - - // Most Schedule I buildable/grid items have a GUID inherited from a base class. - // Use reflection so this survives minor EA API shifts. - object guid = ReadMember(pot, "GUID") ?? ReadMember(pot, "Guid") ?? ReadMember(pot, "guid"); - if (guid != null && !string.IsNullOrWhiteSpace(guid.ToString())) - return guid.ToString(); - - // Fallback for testing only. Not stable across reloads, but better than hard failing. - return $"scene:{pot.GetInstanceID()}"; - } - - private static object ReadMember(object instance, string name) - { - if (instance == null || string.IsNullOrWhiteSpace(name)) - return null; - - Type type = instance.GetType(); - - PropertyInfo prop = AccessTools.Property(type, name); - if (prop != null) - { - try { return prop.GetValue(instance); } catch { } - } - - FieldInfo field = AccessTools.Field(type, name); - if (field != null) - { - try { return field.GetValue(instance); } catch { } - } - - return null; - } - } - - internal static class SoilHelper - { - private static readonly Dictionary RemainingUsesByPotKey = new(); - - public static int DecrementRemainingSoilUses(Pot pot) - { - if (pot == null) - return 0; - - string key = PotKeyHelper.GetPotKey(pot); - if (string.IsNullOrWhiteSpace(key)) - return 0; - - int current = RemainingUsesByPotKey.TryGetValue(key, out int uses) ? uses : 0; - int next = Math.Max(0, current - 1); - - RemainingUsesByPotKey[key] = next; - return next; - } - - public static void SetRemainingSoilUses(Pot pot, int uses) - { - if (pot == null) - return; - - string key = PotKeyHelper.GetPotKey(pot); - if (string.IsNullOrWhiteSpace(key)) - return; - - RemainingUsesByPotKey[key] = uses; - - if (AbsorbentSoilMod.VerboseLogging) - MelonLogger.Msg($"Tracked soil uses for pot '{key}': {uses}"); - } - - public static int GetRemainingSoilUses(Pot pot) - { - if (pot == null) - return 0; - - string key = PotKeyHelper.GetPotKey(pot); - if (string.IsNullOrWhiteSpace(key)) - return 0; - - return RemainingUsesByPotKey.TryGetValue(key, out int uses) ? uses : 0; - } - - public static bool CanCaptureNewAdditives(Pot pot) - { - return GetRemainingSoilUses(pot) > 1; - } - - public static bool CanReapplyRetainedAdditives(Pot pot) - { - return GetRemainingSoilUses(pot) > 0; - } - - public static void Forget(Pot pot) - { - string key = PotKeyHelper.GetPotKey(pot); - if (!string.IsNullOrWhiteSpace(key)) - RemainingUsesByPotKey.Remove(key); - } - } - - [HarmonyPatch(typeof(Pot), "ApplyAdditive")] - internal static class Pot_ApplyAdditive_Patch - { - private static bool _suppressCapture; - - public static void Postfix(Pot __instance, AdditiveDefinition __result, string additiveID, bool isInitialApplication) - { - try - { - if (_suppressCapture) - return; - - if (__instance == null || __result == null || string.IsNullOrWhiteSpace(additiveID)) - return; - - if (!SoilHelper.CanCaptureNewAdditives(__instance)) - return; - - AdditiveMemory.Remember(__instance, additiveID); - } - catch (Exception ex) - { - MelonLogger.Warning($"ApplyAdditive postfix failed: {ex}"); - } - } - - public static void ReapplyWithoutRecapture(Pot pot, string additiveID) - { - if (pot == null || string.IsNullOrWhiteSpace(additiveID)) - return; - - MethodInfo applyAdditive = AccessTools.Method(typeof(Pot), "ApplyAdditive"); - if (applyAdditive == null) - { - MelonLogger.Warning("Could not find Pot.ApplyAdditive via reflection."); - return; - } - - try - { - _suppressCapture = true; - applyAdditive.Invoke(pot, new object[] { additiveID, true }); - } - catch (Exception ex) - { - MelonLogger.Warning($"Failed to reapply additive '{additiveID}': {ex}"); - } - finally - { - _suppressCapture = false; - } - } - } - - [HarmonyPatch(typeof(Plant), nameof(Plant.Initialize))] - internal static class Plant_Initialize_Patch - { - public static void Postfix(Plant __instance) - { - try - { - MelonLogger.Msg($"Plant.Initialize fired. Plant={__instance?.name}, Pot={__instance?.Pot?.name}"); - - if (__instance == null || __instance.Pot == null) - return; - - Pot actualPot = __instance.Pot; - var additiveIds = AdditiveMemory.Get(actualPot); - - MelonLogger.Msg($"Plant.Initialize pot key={PotKeyHelper.GetPotKey(actualPot)}, remembered additives={additiveIds.Count}"); - - foreach (string additiveId in additiveIds) - Pot_ApplyAdditive_Patch.ReapplyWithoutRecapture(actualPot, additiveId); - } - catch (Exception ex) - { - MelonLogger.Warning($"Plant.Initialize postfix failed: {ex}"); - } - } - } - - [HarmonyPatch(typeof(Pot), "OnPlantFullyHarvested")] - internal static class Pot_OnPlantFullyHarvested_Patch - { - private static void Postfix(Pot __instance) - { - try - { - int remaining = SoilHelper.DecrementRemainingSoilUses(__instance); - - MelonLogger.Msg($"[Absorbent Soil] Harvest consumed soil use. Remaining uses={remaining}"); - - if (remaining <= 0) - { - AdditiveMemory.Forget(__instance); - SoilHelper.Forget(__instance); - MelonLogger.Msg($"[Absorbent Soil] Soil depleted. Cleared additives for pot '{PotKeyHelper.GetPotKey(__instance)}'."); - } - } - catch (Exception ex) - { - MelonLogger.Warning($"OnPlantFullyHarvested postfix failed: {ex}"); - } - } - } - - [HarmonyPatch(typeof(Plant), nameof(Plant.AdditiveApplied))] - internal static class Plant_AdditiveApplied_Patch - { - public static void Postfix(Plant __instance, AdditiveDefinition additive, bool isInitialApplication) - { - try - { - MelonLogger.Msg($"Plant.AdditiveApplied fired. Plant={__instance?.name}, Pot={__instance?.Pot?.name}, Additive={additive?.ID}, Initial={isInitialApplication}"); - - if (__instance == null || __instance.Pot == null || additive == null) - return; - - string additiveId = additive.ID; - if (string.IsNullOrWhiteSpace(additiveId)) - return; - - AdditiveMemory.Remember(__instance.Pot, additiveId); - } - catch (Exception ex) - { - MelonLogger.Warning($"Plant.AdditiveApplied postfix failed: {ex}"); - } - } - } - - [HarmonyPatch(typeof(PourSoilTask), "OnInitialPour")] - internal static class PourSoilTask_OnInitialPour_Patch - { - private static unsafe void Postfix(PourSoilTask __instance) - { - try - { - nint basePtr = (nint)IL2CPP.Il2CppObjectBaseToPtrNotNull(__instance); - - nint soilDefPtr = *(nint*)(basePtr + 0xD0); - nint growContainerPtr = *(nint*)(basePtr + 0xE8); - - SoilDefinition soilDef = soilDefPtr != 0 - ? new SoilDefinition(soilDefPtr) - : null; - - GrowContainer growContainer = growContainerPtr != 0 - ? new GrowContainer(growContainerPtr) - : null; - - Pot pot = growContainer?.TryCast(); - - MelonLogger.Msg($"PourSoilTask.OnInitialPour offset-read fired. Soil={soilDef?.ID}, Uses={soilDef?.Uses}, Pot={pot?.name}"); - - if (pot == null || soilDef == null) - return; - - SoilHelper.SetRemainingSoilUses(pot, soilDef.Uses); - AdditiveMemory.Forget(pot); - - MelonLogger.Msg($"[Absorbent Soil] Fresh soil added. Pot='{PotKeyHelper.GetPotKey(pot)}', Soil='{soilDef.ID}', Uses={soilDef.Uses}. Cleared old additives."); - } - catch (Exception ex) - { - MelonLogger.Warning($"PourSoilTask.OnInitialPour postfix failed: {ex}"); - } - } - } - - [HarmonyPatch(typeof(Pot), "Destroy")] - internal static class Pot_Destroy_Patch - { - public static void Prefix(Pot __instance) - { - // If the pot itself is destroyed/sold, stop tracking its additives. - try { AdditiveMemory.Forget(__instance); } - catch { } - } - } } diff --git a/AbsorbentSoil/Helpers/PotKeyHelper.cs b/AbsorbentSoil/Helpers/PotKeyHelper.cs new file mode 100644 index 0000000..544dfc8 --- /dev/null +++ b/AbsorbentSoil/Helpers/PotKeyHelper.cs @@ -0,0 +1,47 @@ +using System; +using System.Reflection; +using HarmonyLib; +using Il2CppScheduleOne.ObjectScripts; + +namespace AbsorbentSoil +{ + internal static class PotKeyHelper + { + public static string GetPotKey(Pot pot) + { + if (pot == null) + return string.Empty; + + // Most Schedule I buildable/grid items have a GUID inherited from a base class. + // Use reflection so this survives minor EA API shifts. + object guid = ReadMember(pot, "GUID") ?? ReadMember(pot, "Guid") ?? ReadMember(pot, "guid"); + if (guid != null && !string.IsNullOrWhiteSpace(guid.ToString())) + return guid.ToString(); + + // Fallback for testing only. Not stable across reloads, but better than hard failing. + return $"scene:{pot.GetInstanceID()}"; + } + + private static object ReadMember(object instance, string name) + { + if (instance == null || string.IsNullOrWhiteSpace(name)) + return null; + + Type type = instance.GetType(); + + PropertyInfo prop = AccessTools.Property(type, name); + if (prop != null) + { + try { return prop.GetValue(instance); } catch { } + } + + FieldInfo field = AccessTools.Field(type, name); + if (field != null) + { + try { return field.GetValue(instance); } catch { } + } + + return null; + } + } +} diff --git a/AbsorbentSoil/Helpers/SaveSlotHelper.cs b/AbsorbentSoil/Helpers/SaveSlotHelper.cs new file mode 100644 index 0000000..9e1b61e --- /dev/null +++ b/AbsorbentSoil/Helpers/SaveSlotHelper.cs @@ -0,0 +1,37 @@ +using Il2CppScheduleOne.Persistence; +using MelonLoader; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +internal static class SaveSlotHelper +{ + public static string GetCurrentSaveKey() + { + try + { + string path = LoadManager.Instance?.LoadedGameFolderPath; + + if (!string.IsNullOrWhiteSpace(path)) + return HashPath(path); + + string saveName = SaveManager.Instance?.SaveName; + + if (!string.IsNullOrWhiteSpace(saveName)) + return SaveManager.SanitizeFileName(saveName); + + return "global"; + } + catch + { + return "global"; + } + } + + private static string HashPath(string path) + { + using var sha1 = SHA1.Create(); + byte[] bytes = sha1.ComputeHash(Encoding.UTF8.GetBytes(path.ToLowerInvariant())); + return System.BitConverter.ToString(bytes).Replace("-", "").Substring(0, 12); + } +} \ No newline at end of file diff --git a/AbsorbentSoil/Patches/PlantAdditiveAppliedPatch.cs b/AbsorbentSoil/Patches/PlantAdditiveAppliedPatch.cs new file mode 100644 index 0000000..4a7957d --- /dev/null +++ b/AbsorbentSoil/Patches/PlantAdditiveAppliedPatch.cs @@ -0,0 +1,33 @@ +using System; +using HarmonyLib; +using Il2CppScheduleOne.Growing; +using Il2CppScheduleOne.ItemFramework; +using MelonLoader; + +namespace AbsorbentSoil +{ + [HarmonyPatch(typeof(Plant), nameof(Plant.AdditiveApplied))] + internal static class Plant_AdditiveApplied_Patch + { + public static void Postfix(Plant __instance, AdditiveDefinition additive, bool isInitialApplication) + { + try + { + MelonLogger.Msg($"Plant.AdditiveApplied fired. Plant={__instance?.name}, Pot={__instance?.Pot?.name}, Additive={additive?.ID}, Initial={isInitialApplication}"); + + if (__instance == null || __instance.Pot == null || additive == null) + return; + + string additiveId = additive.ID; + if (string.IsNullOrWhiteSpace(additiveId)) + return; + + AdditiveMemory.Remember(__instance.Pot, additiveId); + } + catch (Exception ex) + { + MelonLogger.Warning($"Plant.AdditiveApplied postfix failed: {ex}"); + } + } + } +} diff --git a/AbsorbentSoil/Patches/PlantInitializePatch.cs b/AbsorbentSoil/Patches/PlantInitializePatch.cs new file mode 100644 index 0000000..39b1f56 --- /dev/null +++ b/AbsorbentSoil/Patches/PlantInitializePatch.cs @@ -0,0 +1,37 @@ +using System; +using HarmonyLib; +using Il2CppScheduleOne.Growing; +using Il2CppScheduleOne.ObjectScripts; +using MelonLoader; + +namespace AbsorbentSoil +{ + [HarmonyPatch(typeof(Plant), nameof(Plant.Initialize))] + internal static class Plant_Initialize_Patch + { + public static void Postfix(Plant __instance) + { + try + { + PersistenceStore.EnsureLoaded(); + + MelonLogger.Msg($"Plant.Initialize fired. Plant={__instance?.name}, Pot={__instance?.Pot?.name}"); + + if (__instance == null || __instance.Pot == null) + return; + + Pot actualPot = __instance.Pot; + var additiveIds = AdditiveMemory.Get(actualPot); + + MelonLogger.Msg($"Plant.Initialize pot key={PotKeyHelper.GetPotKey(actualPot)}, remembered additives={additiveIds.Count}"); + + foreach (string additiveId in additiveIds) + Pot_ApplyAdditive_Patch.ReapplyWithoutRecapture(actualPot, additiveId); + } + catch (Exception ex) + { + MelonLogger.Warning($"Plant.Initialize postfix failed: {ex}"); + } + } + } +} diff --git a/AbsorbentSoil/Patches/PotApplyAdditivePatch.cs b/AbsorbentSoil/Patches/PotApplyAdditivePatch.cs new file mode 100644 index 0000000..a4607f8 --- /dev/null +++ b/AbsorbentSoil/Patches/PotApplyAdditivePatch.cs @@ -0,0 +1,63 @@ +using System; +using System.Reflection; +using HarmonyLib; +using Il2CppScheduleOne.ItemFramework; +using Il2CppScheduleOne.ObjectScripts; +using MelonLoader; + +namespace AbsorbentSoil +{ + [HarmonyPatch(typeof(Pot), "ApplyAdditive")] + internal static class Pot_ApplyAdditive_Patch + { + private static bool _suppressCapture; + + public static void Postfix(Pot __instance, AdditiveDefinition __result, string additiveID, bool isInitialApplication) + { + try + { + if (_suppressCapture) + return; + + if (__instance == null || __result == null || string.IsNullOrWhiteSpace(additiveID)) + return; + + if (!SoilHelper.CanCaptureNewAdditives(__instance)) + return; + + AdditiveMemory.Remember(__instance, additiveID); + } + catch (Exception ex) + { + MelonLogger.Warning($"ApplyAdditive postfix failed: {ex}"); + } + } + + public static void ReapplyWithoutRecapture(Pot pot, string additiveID) + { + if (pot == null || string.IsNullOrWhiteSpace(additiveID)) + return; + + MethodInfo applyAdditive = AccessTools.Method(typeof(Pot), "ApplyAdditive"); + if (applyAdditive == null) + { + MelonLogger.Warning("Could not find Pot.ApplyAdditive via reflection."); + return; + } + + try + { + _suppressCapture = true; + applyAdditive.Invoke(pot, new object[] { additiveID, true }); + } + catch (Exception ex) + { + MelonLogger.Warning($"Failed to reapply additive '{additiveID}': {ex}"); + } + finally + { + _suppressCapture = false; + } + } + } +} diff --git a/AbsorbentSoil/Patches/PotHarvestDestroyPatches.cs b/AbsorbentSoil/Patches/PotHarvestDestroyPatches.cs new file mode 100644 index 0000000..6640dc2 --- /dev/null +++ b/AbsorbentSoil/Patches/PotHarvestDestroyPatches.cs @@ -0,0 +1,47 @@ +using System; +using HarmonyLib; +using Il2CppScheduleOne.ObjectScripts; +using MelonLoader; + +namespace AbsorbentSoil +{ + [HarmonyPatch(typeof(Pot), "OnPlantFullyHarvested")] + internal static class Pot_OnPlantFullyHarvested_Patch + { + private static void Postfix(Pot __instance) + { + try + { + int remaining = SoilHelper.DecrementRemainingSoilUses(__instance); + + MelonLogger.Msg($"[Absorbent Soil] Harvest consumed soil use. Remaining uses={remaining}"); + + if (remaining <= 0) + { + AdditiveMemory.Forget(__instance); + SoilHelper.Forget(__instance); + MelonLogger.Msg($"[Absorbent Soil] Soil depleted. Cleared additives for pot '{PotKeyHelper.GetPotKey(__instance)}'."); + } + } + catch (Exception ex) + { + MelonLogger.Warning($"OnPlantFullyHarvested postfix failed: {ex}"); + } + } + } + + [HarmonyPatch(typeof(Pot), "Destroy")] + internal static class Pot_Destroy_Patch + { + public static void Prefix(Pot __instance) + { + // If the pot itself is destroyed/sold, stop tracking its additives. + try + { + AdditiveMemory.Forget(__instance); + SoilHelper.Forget(__instance); + } + catch { } + } + } +} diff --git a/AbsorbentSoil/Patches/PourSoilTaskOnInitialPourPatch.cs b/AbsorbentSoil/Patches/PourSoilTaskOnInitialPourPatch.cs new file mode 100644 index 0000000..b11c28b --- /dev/null +++ b/AbsorbentSoil/Patches/PourSoilTaskOnInitialPourPatch.cs @@ -0,0 +1,50 @@ +using System; +using HarmonyLib; +using Il2CppInterop.Runtime; +using Il2CppScheduleOne.Growing; +using Il2CppScheduleOne.ItemFramework; +using Il2CppScheduleOne.ObjectScripts; +using Il2CppScheduleOne.PlayerTasks.Tasks; +using MelonLoader; + +namespace AbsorbentSoil +{ + [HarmonyPatch(typeof(PourSoilTask), "OnInitialPour")] + internal static class PourSoilTask_OnInitialPour_Patch + { + private static unsafe void Postfix(PourSoilTask __instance) + { + try + { + nint basePtr = (nint)IL2CPP.Il2CppObjectBaseToPtrNotNull(__instance); + + nint soilDefPtr = *(nint*)(basePtr + 0xD0); + nint growContainerPtr = *(nint*)(basePtr + 0xE8); + + SoilDefinition soilDef = soilDefPtr != 0 + ? new SoilDefinition(soilDefPtr) + : null; + + GrowContainer growContainer = growContainerPtr != 0 + ? new GrowContainer(growContainerPtr) + : null; + + Pot pot = growContainer?.TryCast(); + + MelonLogger.Msg($"PourSoilTask.OnInitialPour offset-read fired. Soil={soilDef?.ID}, Uses={soilDef?.Uses}, Pot={pot?.name}"); + + if (pot == null || soilDef == null) + return; + + SoilHelper.SetRemainingSoilUses(pot, soilDef.Uses); + AdditiveMemory.Forget(pot); + + MelonLogger.Msg($"[Absorbent Soil] Fresh soil added. Pot='{PotKeyHelper.GetPotKey(pot)}', Soil='{soilDef.ID}', Uses={soilDef.Uses}. Cleared old additives."); + } + catch (Exception ex) + { + MelonLogger.Warning($"PourSoilTask.OnInitialPour postfix failed: {ex}"); + } + } + } +} diff --git a/AbsorbentSoil/State/AdditiveMemory.cs b/AbsorbentSoil/State/AdditiveMemory.cs new file mode 100644 index 0000000..a153d0c --- /dev/null +++ b/AbsorbentSoil/State/AdditiveMemory.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Il2CppScheduleOne.ObjectScripts; +using MelonLoader; + +namespace AbsorbentSoil +{ + internal static class AdditiveMemory + { + private static readonly Dictionary> AdditivesByPotKey = new(); + + public static void Remember(Pot pot, string additiveId) + { + if (pot == null || string.IsNullOrWhiteSpace(additiveId)) + return; + + string key = PotKeyHelper.GetPotKey(pot); + if (string.IsNullOrWhiteSpace(key)) + return; + + if (!AdditivesByPotKey.TryGetValue(key, out HashSet set)) + { + set = new HashSet(StringComparer.OrdinalIgnoreCase); + AdditivesByPotKey[key] = set; + } + + if (set.Add(additiveId)) + { + if (AbsorbentSoilMod.VerboseLogging) + MelonLogger.Msg($"Remembered additive '{additiveId}' for pot '{key}'."); + + PersistenceStore.SaveNow(); + } + } + + public static IReadOnlyList Get(Pot pot) + { + if (pot == null) + return Array.Empty(); + + string key = PotKeyHelper.GetPotKey(pot); + if (string.IsNullOrWhiteSpace(key)) + return Array.Empty(); + + return AdditivesByPotKey.TryGetValue(key, out HashSet set) + ? set.ToArray() + : Array.Empty(); + } + + public static bool HasStored(Pot pot) + { + if (pot == null) + return false; + + string key = PotKeyHelper.GetPotKey(pot); + return !string.IsNullOrWhiteSpace(key) && + AdditivesByPotKey.TryGetValue(key, out HashSet set) && + set.Count > 0; + } + + public static void Forget(Pot pot) + { + if (pot == null) + return; + + string key = PotKeyHelper.GetPotKey(pot); + if (!string.IsNullOrWhiteSpace(key) && AdditivesByPotKey.Remove(key)) + PersistenceStore.SaveNow(); + } + + public static Dictionary> Export() + { + return AdditivesByPotKey.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToList(), + StringComparer.OrdinalIgnoreCase); + } + + public static void Import(Dictionary> additivesByPotKey) + { + AdditivesByPotKey.Clear(); + + if (additivesByPotKey == null) + return; + + foreach (var kvp in additivesByPotKey) + { + if (string.IsNullOrWhiteSpace(kvp.Key) || kvp.Value == null) + continue; + + var set = new HashSet( + kvp.Value.Where(id => !string.IsNullOrWhiteSpace(id)), + StringComparer.OrdinalIgnoreCase); + + if (set.Count > 0) + AdditivesByPotKey[kvp.Key] = set; + } + } + + // Memory-only clear for changing scenes/saves. Do not call SaveNow here or you can wipe the JSON. + public static void Clear() => AdditivesByPotKey.Clear(); + } +} diff --git a/AbsorbentSoil/State/PersistenceStore.cs b/AbsorbentSoil/State/PersistenceStore.cs new file mode 100644 index 0000000..553d139 --- /dev/null +++ b/AbsorbentSoil/State/PersistenceStore.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using MelonLoader; +using MelonLoader.Preferences; +using MelonLoader.Utils; +using System.Security.Cryptography; +using System.Text; +using Il2CppScheduleOne.Persistence; + +namespace AbsorbentSoil +{ + internal static class PersistenceStore + { + private const int CurrentVersion = 1; + private static bool _loaded; + private static bool _isLoading; + + private static string DirectoryPath => Path.Combine( + MelonEnvironment.UserDataDirectory, + "AbsorbentSoil", + GetCurrentSaveKey()); + + private static string FilePath => Path.Combine(DirectoryPath, "state.json"); + + private static string GetCurrentSaveKey() + { + try + { + string loadedPath = LoadManager.Instance?.LoadedGameFolderPath; + + if (!string.IsNullOrWhiteSpace(loadedPath)) + return HashPath(loadedPath); + + string saveName = SaveManager.Instance?.SaveName; + + if (!string.IsNullOrWhiteSpace(saveName)) + return SaveManager.SanitizeFileName(saveName); + + return "global"; + } + catch + { + return "global"; + } + } + + private static string HashPath(string path) + { + using SHA1 sha1 = SHA1.Create(); + + byte[] bytes = sha1.ComputeHash( + Encoding.UTF8.GetBytes(path.ToLowerInvariant())); + + return BitConverter + .ToString(bytes) + .Replace("-", "") + .Substring(0, 12); + } + + public static void EnsureLoaded() + { + if (_loaded) + return; + + LoadNow(); + } + + public static void ReloadNow() + { + _loaded = false; + LoadNow(); + } + + public static void LoadNow() + { + if (_isLoading) + return; + + _isLoading = true; + try + { + _loaded = true; + if (AbsorbentSoilMod.VerboseLogging) + { + MelonLogger.Msg($"[Absorbent Soil] LoadedGameFolderPath = {LoadManager.Instance?.LoadedGameFolderPath}"); + MelonLogger.Msg($"[Absorbent Soil] SaveName = {SaveManager.Instance?.SaveName}"); + MelonLogger.Msg($"[Absorbent Soil] SaveKey = {GetCurrentSaveKey()}"); + MelonLogger.Msg($"[Absorbent Soil] Persistence file = {FilePath}"); + } + + if (!File.Exists(FilePath)) + { + if (AbsorbentSoilMod.VerboseLogging) + MelonLogger.Msg("[Absorbent Soil] No persistence file found yet."); + return; + } + + string json = File.ReadAllText(FilePath); + PersistedState state = JsonSerializer.Deserialize(json); + if (state == null || state.Pots == null) + return; + + AdditiveMemory.Import(state.Pots.ToDictionary( + kvp => kvp.Key, + kvp => (IEnumerable)(kvp.Value?.Additives ?? new List()), + StringComparer.OrdinalIgnoreCase)); + + SoilHelper.Import(state.Pots.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value?.RemainingUses ?? 0, + StringComparer.OrdinalIgnoreCase)); + + MelonLogger.Msg($"[Absorbent Soil] Loaded persisted state for {state.Pots.Count} pot(s)."); + } + catch (Exception ex) + { + MelonLogger.Warning($"[Absorbent Soil] Failed to load persisted state: {ex}"); + } + finally + { + _isLoading = false; + } + } + + public static void SaveNow() + { + if (_isLoading) + return; + + try + { + Directory.CreateDirectory(DirectoryPath); + + Dictionary> additiveSnapshot = AdditiveMemory.Export(); + Dictionary soilSnapshot = SoilHelper.Export(); + + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string key in additiveSnapshot.Keys) + keys.Add(key); + foreach (string key in soilSnapshot.Keys) + keys.Add(key); + + var state = new PersistedState + { + Version = CurrentVersion, + UpdatedUtc = DateTime.UtcNow, + Pots = new Dictionary(StringComparer.OrdinalIgnoreCase) + }; + + foreach (string key in keys) + { + additiveSnapshot.TryGetValue(key, out List additives); + soilSnapshot.TryGetValue(key, out int remainingUses); + + additives ??= new List(); + + // Avoid keeping dead/empty records forever. + if (remainingUses <= 0 && additives.Count == 0) + continue; + + state.Pots[key] = new PersistedPotState + { + RemainingUses = remainingUses, + Additives = additives + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) + .ToList() + }; + } + + var options = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(FilePath, JsonSerializer.Serialize(state, options)); + + if (AbsorbentSoilMod.VerboseLogging) + MelonLogger.Msg($"[Absorbent Soil] Saved persisted state for {state.Pots.Count} pot(s)."); + } + catch (Exception ex) + { + MelonLogger.Warning($"[Absorbent Soil] Failed to save persisted state: {ex}"); + } + } + + private sealed class PersistedState + { + public int Version { get; set; } + public DateTime UpdatedUtc { get; set; } + public Dictionary Pots { get; set; } = new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class PersistedPotState + { + public int RemainingUses { get; set; } + public List Additives { get; set; } = new(); + } + } +} diff --git a/AbsorbentSoil/State/SoilHelper.cs b/AbsorbentSoil/State/SoilHelper.cs new file mode 100644 index 0000000..9408b69 --- /dev/null +++ b/AbsorbentSoil/State/SoilHelper.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using Il2CppScheduleOne.ObjectScripts; +using MelonLoader; + +namespace AbsorbentSoil +{ + internal static class SoilHelper + { + private static readonly Dictionary RemainingUsesByPotKey = new(); + + public static int DecrementRemainingSoilUses(Pot pot) + { + if (pot == null) + return 0; + + string key = PotKeyHelper.GetPotKey(pot); + if (string.IsNullOrWhiteSpace(key)) + return 0; + + int current = RemainingUsesByPotKey.TryGetValue(key, out int uses) ? uses : 0; + int next = Math.Max(0, current - 1); + + RemainingUsesByPotKey[key] = next; + PersistenceStore.SaveNow(); + return next; + } + + public static void SetRemainingSoilUses(Pot pot, int uses) + { + if (pot == null) + return; + + string key = PotKeyHelper.GetPotKey(pot); + if (string.IsNullOrWhiteSpace(key)) + return; + + RemainingUsesByPotKey[key] = uses; + + if (AbsorbentSoilMod.VerboseLogging) + MelonLogger.Msg($"Tracked soil uses for pot '{key}': {uses}"); + + PersistenceStore.SaveNow(); + } + + public static int GetRemainingSoilUses(Pot pot) + { + if (pot == null) + return 0; + + string key = PotKeyHelper.GetPotKey(pot); + if (string.IsNullOrWhiteSpace(key)) + return 0; + + return RemainingUsesByPotKey.TryGetValue(key, out int uses) ? uses : 0; + } + + public static bool CanCaptureNewAdditives(Pot pot) + { + return GetRemainingSoilUses(pot) > 1; + } + + public static bool CanReapplyRetainedAdditives(Pot pot) + { + return GetRemainingSoilUses(pot) > 0; + } + + public static void Forget(Pot pot) + { + string key = PotKeyHelper.GetPotKey(pot); + if (!string.IsNullOrWhiteSpace(key) && RemainingUsesByPotKey.Remove(key)) + PersistenceStore.SaveNow(); + } + + public static Dictionary Export() + { + return new Dictionary(RemainingUsesByPotKey, StringComparer.OrdinalIgnoreCase); + } + + public static void Import(Dictionary remainingUsesByPotKey) + { + RemainingUsesByPotKey.Clear(); + + if (remainingUsesByPotKey == null) + return; + + foreach (var kvp in remainingUsesByPotKey) + { + if (!string.IsNullOrWhiteSpace(kvp.Key)) + RemainingUsesByPotKey[kvp.Key] = Math.Max(0, kvp.Value); + } + } + + public static void Clear() => RemainingUsesByPotKey.Clear(); + } +} diff --git a/AbsorbentSoil/bin/Release/net6.0/AbsorbentSoil.pdb b/AbsorbentSoil/bin/Release/net6.0/AbsorbentSoil.pdb index f219977..b55dff9 100644 Binary files a/AbsorbentSoil/bin/Release/net6.0/AbsorbentSoil.pdb and b/AbsorbentSoil/bin/Release/net6.0/AbsorbentSoil.pdb differ diff --git a/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.AssemblyInfo.cs b/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.AssemblyInfo.cs index bc3144f..f285866 100644 --- a/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.AssemblyInfo.cs +++ b/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("AbsorbentSoil")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+df8a62295eea38ad16e750280eea878da48af3ae")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+162a1ce7951370e4cbd8c674c284b30f8a85cbd4")] [assembly: System.Reflection.AssemblyProductAttribute("AbsorbentSoil")] [assembly: System.Reflection.AssemblyTitleAttribute("AbsorbentSoil")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.AssemblyInfoInputs.cache b/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.AssemblyInfoInputs.cache index 4f9bf71..73a7919 100644 --- a/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.AssemblyInfoInputs.cache +++ b/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.AssemblyInfoInputs.cache @@ -1 +1 @@ -205b227cc6bf36a9df78289d110ec64054a771277aee03d496358bdc65fdfb7b +67d62e0901b35175e30da93a43a11d964fe004e1a9681a2e3903abe5b9ef6961 diff --git a/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.csproj.CoreCompileInputs.cache b/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.csproj.CoreCompileInputs.cache index 62a182f..8297418 100644 --- a/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.csproj.CoreCompileInputs.cache +++ b/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.csproj.CoreCompileInputs.cache @@ -1 +1 @@ -ddb36bf3fc2e77af19df7cc3d5e652bab5098b8451766fb4c9483694133ef288 +5c030080472ae1536e3626b995d635d8af1656efb3cdf494e4087a4281d4934d diff --git a/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.dll b/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.dll index 6da0a1d..f598550 100644 Binary files a/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.dll and b/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.dll differ diff --git a/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.pdb b/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.pdb index f219977..b55dff9 100644 Binary files a/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.pdb and b/AbsorbentSoil/obj/Release/net6.0/AbsorbentSoil.pdb differ diff --git a/AbsorbentSoil/obj/Release/net6.0/ref/AbsorbentSoil.dll b/AbsorbentSoil/obj/Release/net6.0/ref/AbsorbentSoil.dll index 145db4b..d279ece 100644 Binary files a/AbsorbentSoil/obj/Release/net6.0/ref/AbsorbentSoil.dll and b/AbsorbentSoil/obj/Release/net6.0/ref/AbsorbentSoil.dll differ diff --git a/AbsorbentSoil/obj/Release/net6.0/refint/AbsorbentSoil.dll b/AbsorbentSoil/obj/Release/net6.0/refint/AbsorbentSoil.dll index 145db4b..d279ece 100644 Binary files a/AbsorbentSoil/obj/Release/net6.0/refint/AbsorbentSoil.dll and b/AbsorbentSoil/obj/Release/net6.0/refint/AbsorbentSoil.dll differ