From a08d22d1c79f75ce87eace403bff0c2cfefd2bab Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 1 Oct 2026 22:20:17 -0700 Subject: [PATCH 1/2] feat(saveables): expose public saveable base with legacy compatibility --- .../Saveables/SaveableCompatibilityTests.cs | 171 +++++++++++ S1API/Entities/NPC.cs | 4 +- S1API/Internal/Abstraction/Registerable.cs | 5 +- S1API/Internal/Abstraction/Saveable.cs | 288 ++--------------- S1API/Quests/Quest.cs | 6 +- S1API/Saveables/Saveable.cs | 290 ++++++++++++++++++ S1API/Saveables/SaveableAutoRegistry.cs | 6 +- S1API/docs/getting-started.md | 1 - S1API/docs/save-system.md | 11 +- 9 files changed, 510 insertions(+), 272 deletions(-) create mode 100644 S1API.Tests/Saveables/SaveableCompatibilityTests.cs create mode 100644 S1API/Saveables/Saveable.cs diff --git a/S1API.Tests/Saveables/SaveableCompatibilityTests.cs b/S1API.Tests/Saveables/SaveableCompatibilityTests.cs new file mode 100644 index 00000000..6a638f88 --- /dev/null +++ b/S1API.Tests/Saveables/SaveableCompatibilityTests.cs @@ -0,0 +1,171 @@ +using System.ComponentModel; +using System.Reflection; +using S1API.Internal.Abstraction; +using S1API.Saveables; +using Saveable = S1API.Saveables.Saveable; +#pragma warning disable CS0618 // These fixtures intentionally exercise the legacy API. +using LegacySaveable = S1API.Internal.Abstraction.Saveable; + +namespace S1API.Tests.Saveables; + +public sealed class SaveableCompatibilityTests +{ + [Fact] + public void LegacySurfaceRemainsAvailableWithWarningOnlyDeprecation() + { + ObsoleteAttribute obsolete = typeof(LegacySaveable).GetCustomAttribute()!; + Assert.False(obsolete.IsError); + Assert.Contains("S1API.Saveables.Saveable", obsolete.Message); + Assert.Null(typeof(Saveable).GetCustomAttribute()); + Assert.Equal(EditorBrowsableState.Always, typeof(Saveable).GetCustomAttribute()!.State); + Assert.True(typeof(Saveable).IsAssignableFrom(typeof(LegacySaveable))); + Assert.Equal(typeof(LegacySaveable), typeof(global::S1API.Entities.NPC).BaseType); + Assert.Equal(typeof(LegacySaveable), typeof(global::S1API.Quests.Quest).BaseType); + + const BindingFlags declared = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + Assert.True(typeof(LegacySaveable).GetProperty("LoadOrder", declared)!.GetMethod!.IsVirtual); + foreach (string hook in new[] { "OnLoaded", "OnSaved" }) + { + MethodInfo method = typeof(LegacySaveable).GetMethod(hook, declared)!; + Assert.True(method.IsFamily); + Assert.True(method.IsVirtual); + Assert.False(method.IsFinal); + Assert.True((method.Attributes & MethodAttributes.NewSlot) != 0); + } + Assert.NotNull(typeof(LegacySaveable).GetMethod("RequestGameSave", declared, Type.EmptyTypes)); + MethodInfo request = typeof(LegacySaveable).GetMethod("RequestGameSave", declared, new[] { typeof(bool) })!; + Assert.Equal("immediate", request.GetParameters()[0].Name); + Assert.True(typeof(Registerable).IsPublic); + Assert.Equal(EditorBrowsableState.Never, typeof(Registerable).GetCustomAttribute()!.State); + } + + [Fact] + public void DiscoveryAcceptsBothDirectBasesAndKeepsIndirectClassesExcluded() + { + MethodInfo discover = typeof(SaveableAutoRegistry).GetMethod("IsDirectSaveableInheritor", BindingFlags.NonPublic | BindingFlags.Static)!; + bool Accepts(Type type) => (bool)discover.Invoke(null, new object[] { type })!; + + Assert.True(Accepts(typeof(CurrentFixture))); + Assert.True(Accepts(typeof(LegacyFixture))); + Assert.False(Accepts(typeof(IndirectCurrentFixture))); + Assert.False(Accepts(typeof(IndirectLegacyFixture))); + Assert.False(Accepts(typeof(Saveable))); + Assert.False(Accepts(typeof(LegacySaveable))); + Assert.False(Accepts(typeof(global::S1API.Entities.NPC))); + Assert.False(Accepts(typeof(global::S1API.Quests.Quest))); + Assert.False(Accepts(typeof(string))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LifecycleAndLegacyJsonLoadingDispatchThroughBothBases(bool legacy) + { + Saveable instance = legacy ? new LegacyFixture() : new CurrentFixture(); + IFixture fixture = (IFixture)instance; + Assert.Equal(SaveableLoadOrder.AfterBaseGame, instance.LoadOrder); + fixture.LoadBeforeBaseGame = true; + Assert.Equal(SaveableLoadOrder.BeforeBaseGame, instance.LoadOrder); + string folder = Path.Combine(Path.GetTempPath(), "S1API-Saveable-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + try + { + ((IRegisterable)instance).CreateInternal(); + ((ISaveable)instance).LoadInternal(folder); + Assert.Equal(1, fixture.Loaded); + Assert.Equal(7, fixture.Value); + File.WriteAllText(Path.Combine(folder, "value.json"), "42"); + ((ISaveable)instance).LoadInternal(folder); + Assert.Equal(42, fixture.Value); + Assert.Equal(2, fixture.Loaded); + ((ISaveable)instance).OnSaved(); + ((IRegisterable)instance).DestroyInternal(); + Assert.Equal(1, fixture.Saved); + Assert.Equal(1, fixture.Created); + Assert.Equal(1, fixture.Destroyed); + } + finally + { + Directory.Delete(folder, true); + } + } + +#if MONOMELON + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SavingPreservesFieldNamesAndNullDeletion(bool legacy) + { + Saveable instance = legacy ? new LegacyFixture() : new CurrentFixture(); + IFixture fixture = (IFixture)instance; + string folder = Path.Combine(Path.GetTempPath(), "S1API-Saveable-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + try + { + File.WriteAllText(Path.Combine(folder, "optional.json"), "99"); + var extra = new List(); + ((ISaveable)instance).SaveInternal(folder, ref extra); + Assert.Equal("7", File.ReadAllText(Path.Combine(folder, "value.json"))); + Assert.Equal(new[] { "value.json" }, extra); + Assert.False(File.Exists(Path.Combine(folder, "optional.json"))); + Assert.Null(fixture.Optional); + Assert.Equal(1, fixture.Saved); + } + finally + { + Directory.Delete(folder, true); + } + } +#endif + + private interface IFixture + { + int Value { get; } + int? Optional { get; } + bool LoadBeforeBaseGame { get; set; } + int Created { get; } + int Destroyed { get; } + int Loaded { get; } + int Saved { get; } + } + + private class CurrentFixture : Saveable, IFixture + { + [SaveableField("value")] private int _value = 7; + [SaveableField("optional.json")] private int? _optional = null; + public int Value => _value; + public int? Optional => _optional; + public bool LoadBeforeBaseGame { get; set; } + public override SaveableLoadOrder LoadOrder => LoadBeforeBaseGame ? SaveableLoadOrder.BeforeBaseGame : base.LoadOrder; + public int Created { get; private set; } + public int Destroyed { get; private set; } + public int Loaded { get; private set; } + public int Saved { get; private set; } + protected override void OnCreated() => Created++; + protected override void OnDestroyed() => Destroyed++; + protected override void OnLoaded() { base.OnLoaded(); Loaded++; } + protected override void OnSaved() { base.OnSaved(); Saved++; } + } + + private class LegacyFixture : LegacySaveable, IFixture + { + [SaveableField("value")] private int _value = 7; + [SaveableField("optional.json")] private int? _optional = null; + public int Value => _value; + public int? Optional => _optional; + public bool LoadBeforeBaseGame { get; set; } + public override SaveableLoadOrder LoadOrder => LoadBeforeBaseGame ? SaveableLoadOrder.BeforeBaseGame : base.LoadOrder; + public int Created { get; private set; } + public int Destroyed { get; private set; } + public int Loaded { get; private set; } + public int Saved { get; private set; } + protected override void OnCreated() => Created++; + protected override void OnDestroyed() => Destroyed++; + protected override void OnLoaded() { base.OnLoaded(); Loaded++; } + protected override void OnSaved() { base.OnSaved(); Saved++; } + } + + private sealed class IndirectCurrentFixture : CurrentFixture { } + private sealed class IndirectLegacyFixture : LegacyFixture { } +} +#pragma warning restore CS0618 diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 02fb1783..59c0b375 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -125,7 +125,9 @@ namespace S1API.Entities /// , , , and . /// Customer, relationship, and schedule configuration must be done in for proper save/load behavior. /// - public abstract class NPC : Saveable, IEntity, IHealth +#pragma warning disable CS0618 // Preserve the legacy base type for existing NPC mods. + public abstract class NPC : global::S1API.Internal.Abstraction.Saveable, IEntity, IHealth +#pragma warning restore CS0618 { private static readonly Log Logger = new Log("NPC"); // Protected members intended to be used by modders. diff --git a/S1API/Internal/Abstraction/Registerable.cs b/S1API/Internal/Abstraction/Registerable.cs index e0b9b920..a907f811 100644 --- a/S1API/Internal/Abstraction/Registerable.cs +++ b/S1API/Internal/Abstraction/Registerable.cs @@ -1,9 +1,10 @@ namespace S1API.Internal.Abstraction { /// - /// INTERNAL: A registerable base class for use internally. - /// Not intended for modder use. + /// Infrastructure base class for S1API registration and lifecycle hooks. + /// Public so modder-facing API classes can inherit it; not intended for direct modder inheritance. /// + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public abstract class Registerable : IRegisterable { /// diff --git a/S1API/Internal/Abstraction/Saveable.cs b/S1API/Internal/Abstraction/Saveable.cs index f0886f69..278a21df 100644 --- a/S1API/Internal/Abstraction/Saveable.cs +++ b/S1API/Internal/Abstraction/Saveable.cs @@ -1,283 +1,47 @@ -#if MONOMELON -using System.Collections.Generic; -#elif IL2CPPMELON -using Il2CppSystem.Collections.Generic; -#endif - using System; -using System.IO; -using System.Reflection; -using Newtonsoft.Json; -using S1API.Internal.Utils; +using System.ComponentModel; using S1API.Saveables; -#if (IL2CPPMELON) -using S1Datas = Il2CppScheduleOne.Persistence.Datas; -#elif MONOMELON -using S1Datas = ScheduleOne.Persistence.Datas; -#endif - -#if (IL2CPPMELON) -using S1Persistence = Il2CppScheduleOne.Persistence; -#elif MONOMELON -using S1Persistence = ScheduleOne.Persistence; -#endif namespace S1API.Internal.Abstraction { /// - /// Generic wrapper for saveable classes. + /// Compatibility base class for existing mods. Use + /// for new saveable classes. /// - public abstract class Saveable : Registerable, ISaveable + /// + /// Retained for existing compiled mods and scheduled for removal in a future breaking release. + /// No removal version has been set. + /// + [Obsolete("Use S1API.Saveables.Saveable instead. This compatibility type will be removed in a future breaking release.", false)] + [EditorBrowsable(EditorBrowsableState.Never)] + public abstract class Saveable : global::S1API.Saveables.Saveable { - /// - /// Determines when this saveable should load relative to base game saveables. - /// - /// - /// Default is , which loads after base game entities are loaded. - /// Override this property to return if your mod data needs to be - /// available before the base game's ISaveables are loaded. - /// - /// - /// - /// AfterBaseGame (default): Your method is called after base game entities - /// (NPCs, buildings, vehicles) have been loaded. This is the recommended setting for most mods. - /// - /// - /// BeforeBaseGame: Your method is called before base game entities are loaded. - /// Use this only if you need to set up hooks or state that the base game loading process depends on. - /// - /// - /// Note: All saveables are saved at the same time (after base game save), regardless of load order. - /// - /// - /// - /// - /// public class EarlyConfigSaveable : Saveable - /// { - /// public override SaveableLoadOrder LoadOrder => SaveableLoadOrder.BeforeBaseGame; - /// - /// [SaveableField("config")] - /// private ModConfig _config = new ModConfig(); - /// - /// protected override void OnLoaded() - /// { - /// // Base game entities are NOT loaded yet - /// ApplyGlobalSettings(_config); - /// } - /// } - /// - /// - public virtual SaveableLoadOrder LoadOrder => SaveableLoadOrder.AfterBaseGame; + /// + public new virtual SaveableLoadOrder LoadOrder => SaveableLoadOrder.AfterBaseGame; /// - /// Requests the game to perform a save operation. If a game is not currently loaded, - /// the request is ignored and the method returns false. + /// Requests a game save. The immediate parameter is retained for compatibility and ignored. /// - /// This parameter is ignored in v0.4.3+ (kept for backwards compatibility). + /// Ignored, as in the original implementation. /// True if a save was requested; false if the game is not in a savable state. - public static bool RequestGameSave(bool immediate) => RequestGameSave(); + public new static bool RequestGameSave(bool immediate) => + global::S1API.Saveables.Saveable.RequestGameSave(immediate); /// - /// Requests the game to perform a save operation. If a game is not currently loaded, - /// the request is ignored and the method returns false. + /// Requests a game save when a game is loaded. /// /// True if a save was requested; false if the game is not in a savable state. - public static bool RequestGameSave() - { - try - { - var loadManager = S1Persistence.LoadManager.Instance; - if (loadManager == null || !loadManager.IsGameLoaded) - return false; - - var saveManager = S1Persistence.SaveManager.Instance; - if (saveManager == null) - return false; - - saveManager.Save(); - - return true; - } - catch - { - return false; - } - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the internal LoadInternal method. - /// Loads all fields marked with the attribute from JSON files in the specified folder. - /// - /// The folder path containing the save files to load. - void ISaveable.LoadInternal(string folderPath) => - LoadInternal(folderPath); - - /// - /// INTERNAL: Loads all fields marked with the attribute from JSON files in the specified folder. - /// This method uses reflection to find fields with the SaveableField attribute and deserializes their values from JSON files. - /// After loading all fields, it calls the method to allow derived classes to perform additional initialization. - /// - /// The folder path containing the save files to load from. - internal virtual void LoadInternal(string folderPath) - { - FieldInfo[] saveableFields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - string filename = saveableFieldAttribute.SaveName.EndsWith(".json") - ? saveableFieldAttribute.SaveName - : $"{saveableFieldAttribute.SaveName}.json"; - - string saveDataPath = Path.Combine(folderPath, filename); - if (!File.Exists(saveDataPath)) - continue; - - string json = File.ReadAllText(saveDataPath); - Type type = saveableField.FieldType; - object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); - saveableField.SetValue(this, value); - } - - OnLoaded(); - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the internal SaveInternal method. - /// Saves all fields marked with the attribute to JSON files in the specified folder. - /// - /// The folder path where save files should be written. - /// Reference to a list of extra saveable files that should not be deleted during cleanup. - void ISaveable.SaveInternal(string folderPath, ref List extraSaveables) => - SaveInternal(folderPath, ref extraSaveables); - - /// - /// INTERNAL: Saves all fields marked with the attribute to JSON files in the specified folder. - /// This method uses reflection to find fields with the SaveableField attribute and serializes their values to JSON files. - /// Null fields result in their corresponding save files being deleted. Non-null fields are added to the extraSaveables list - /// to prevent the base game from deleting them during cleanup. After saving all fields, it calls the method - /// to allow derived classes to perform additional finalization. - /// - /// The folder path where save files should be written. - /// Reference to a list of extra saveable files that should not be deleted during cleanup. - internal virtual void SaveInternal(string folderPath, ref List extraSaveables) - { - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - string saveFileName = saveableFieldAttribute.SaveName.EndsWith(".json") - ? saveableFieldAttribute.SaveName - : $"{saveableFieldAttribute.SaveName}.json"; - - string saveDataPath = Path.Combine(folderPath, saveFileName); - - object? value = saveableField.GetValue(this); - if (value == null) - // Remove the save if the field is null - File.Delete(saveDataPath); - else - { - // We add this to the extra saveables to prevent the game from deleting it - // Otherwise, it'll delete it after it finishes saving and does clean up - extraSaveables.Add(saveFileName); + public new static bool RequestGameSave() => global::S1API.Saveables.Saveable.RequestGameSave(); - // Write our data - string data = JsonConvert.SerializeObject(value, Formatting.Indented, ISaveable.SerializerSettings); - File.WriteAllText(saveDataPath, data); - } - } + /// + protected new virtual void OnLoaded() => base.OnLoaded(); - OnSaved(); - } + /// + protected new virtual void OnSaved() => base.OnSaved(); - /// - /// INTERNAL: Writes fields marked with into a DynamicSaveData blob - /// to support the base game's consolidated JSON save format. - /// - /// The dynamic save data record to write into. - internal void SaveToDynamic(S1Datas.DynamicSaveData dynamicSaveData) - { - if (dynamicSaveData == null) - return; - - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - object? value = saveableField.GetValue(this); - if (value == null) - continue; // Do not write nulls - - string data = JsonConvert.SerializeObject(value, Formatting.None, ISaveable.SerializerSettings); - // Use the declared save name as the dynamic key - dynamicSaveData.AddData(saveableFieldAttribute.SaveName, data); - } - - OnSaved(); - } - - /// - /// INTERNAL: Reads fields marked with from a DynamicSaveData blob - /// to support the base game's consolidated JSON save format. - /// - /// The dynamic save data record to read from. - internal void LoadFromDynamic(S1Datas.DynamicSaveData dynamicSaveData) - { - if (dynamicSaveData == null) - return; - - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - // Read the raw json for this save name and deserialize to the field type - if (!dynamicSaveData.TryGetData(saveableFieldAttribute.SaveName, out string json) || string.IsNullOrEmpty(json)) - continue; - - Type type = saveableField.FieldType; - object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); - saveableField.SetValue(this, value); - } - - OnLoaded(); - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the virtual OnLoaded method. - /// Called after all saveable fields have been loaded from their respective JSON files. - /// - void ISaveable.OnLoaded() => OnLoaded(); - - /// - /// Called after all saveable fields have been loaded from their respective JSON files. - /// This method can be overridden in derived classes to perform additional initialization - /// or processing after the save data has been restored. - /// - protected virtual void OnLoaded() { } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the virtual OnSaved method. - /// Called after all saveable fields have been saved to their respective JSON files. - /// - void ISaveable.OnSaved() => OnSaved(); - - /// - /// Called after all saveable fields have been saved to their respective JSON files. - /// This method can be overridden in derived classes to perform additional finalization - /// or processing after the save data has been written to disk. - /// - protected virtual void OnSaved() { } + // Keep the original virtual slots for already compiled mod overrides. + internal override SaveableLoadOrder GetLoadOrder() => LoadOrder; + internal override void InvokeOnLoaded() => OnLoaded(); + internal override void InvokeOnSaved() => OnSaved(); } } diff --git a/S1API/Quests/Quest.cs b/S1API/Quests/Quest.cs index fbbefed7..e10281f3 100644 --- a/S1API/Quests/Quest.cs +++ b/S1API/Quests/Quest.cs @@ -42,8 +42,10 @@ namespace S1API.Quests /// /// To create a quest, you must use to instantiate your quest. /// - /// - public abstract class Quest : Saveable + /// +#pragma warning disable CS0618 // Preserve the legacy base type for existing quest mods. + public abstract class Quest : global::S1API.Internal.Abstraction.Saveable +#pragma warning restore CS0618 { /// /// The title of the quest to display for the player. diff --git a/S1API/Saveables/Saveable.cs b/S1API/Saveables/Saveable.cs new file mode 100644 index 00000000..e081753d --- /dev/null +++ b/S1API/Saveables/Saveable.cs @@ -0,0 +1,290 @@ +#if MONOMELON +using System.Collections.Generic; +#elif IL2CPPMELON +using Il2CppSystem.Collections.Generic; +#endif + +using System; +using System.ComponentModel; +using System.IO; +using System.Reflection; +using Newtonsoft.Json; +using S1API.Internal.Utils; +using S1API.Internal.Abstraction; +#if (IL2CPPMELON) +using S1Datas = Il2CppScheduleOne.Persistence.Datas; +#elif MONOMELON +using S1Datas = ScheduleOne.Persistence.Datas; +#endif + +#if (IL2CPPMELON) +using S1Persistence = Il2CppScheduleOne.Persistence; +#elif MONOMELON +using S1Persistence = ScheduleOne.Persistence; +#endif + +namespace S1API.Saveables +{ + /// + /// Base class for mod data persisted in each game save slot. + /// + [EditorBrowsable(EditorBrowsableState.Always)] + public abstract class Saveable : Registerable, ISaveable + { + /// + /// Determines when this saveable should load relative to base game saveables. + /// + /// + /// Default is , which loads after base game entities are loaded. + /// Override this property to return if your mod data needs to be + /// available before the base game's ISaveables are loaded. + /// + /// + /// + /// AfterBaseGame (default): Your method is called after base game entities + /// (NPCs, buildings, vehicles) have been loaded. This is the recommended setting for most mods. + /// + /// + /// BeforeBaseGame: Your method is called before base game entities are loaded. + /// Use this only if you need to set up hooks or state that the base game loading process depends on. + /// + /// + /// Note: All saveables are saved at the same time (after base game save), regardless of load order. + /// + /// + /// + /// + /// public class EarlyConfigSaveable : Saveable + /// { + /// public override SaveableLoadOrder LoadOrder => SaveableLoadOrder.BeforeBaseGame; + /// + /// [SaveableField("config")] + /// private ModConfig _config = new ModConfig(); + /// + /// protected override void OnLoaded() + /// { + /// // Base game entities are NOT loaded yet + /// ApplyGlobalSettings(_config); + /// } + /// } + /// + /// + public virtual SaveableLoadOrder LoadOrder => GetLoadOrder(); + + // Dispatch through the legacy virtual slots when a compatibility subclass is used. + internal virtual SaveableLoadOrder GetLoadOrder() => SaveableLoadOrder.AfterBaseGame; + internal virtual void InvokeOnLoaded() => OnLoaded(); + internal virtual void InvokeOnSaved() => OnSaved(); + + /// + /// Requests the game to perform a save operation. If a game is not currently loaded, + /// the request is ignored and the method returns false. + /// + /// This parameter is ignored in v0.4.3+ (kept for backwards compatibility). + /// True if a save was requested; false if the game is not in a savable state. + public static bool RequestGameSave(bool immediate) => RequestGameSave(); + + /// + /// Requests the game to perform a save operation. If a game is not currently loaded, + /// the request is ignored and the method returns false. + /// + /// True if a save was requested; false if the game is not in a savable state. + public static bool RequestGameSave() + { + try + { + var loadManager = S1Persistence.LoadManager.Instance; + if (loadManager == null || !loadManager.IsGameLoaded) + return false; + + var saveManager = S1Persistence.SaveManager.Instance; + if (saveManager == null) + return false; + + saveManager.Save(); + + return true; + } + catch + { + return false; + } + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the internal LoadInternal method. + /// Loads all fields marked with the attribute from JSON files in the specified folder. + /// + /// The folder path containing the save files to load. + void ISaveable.LoadInternal(string folderPath) => + LoadInternal(folderPath); + + /// + /// INTERNAL: Loads all fields marked with the attribute from JSON files in the specified folder. + /// This method uses reflection to find fields with the SaveableField attribute and deserializes their values from JSON files. + /// After loading all fields, it calls the method to allow derived classes to perform additional initialization. + /// + /// The folder path containing the save files to load from. + internal virtual void LoadInternal(string folderPath) + { + FieldInfo[] saveableFields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + string filename = saveableFieldAttribute.SaveName.EndsWith(".json") + ? saveableFieldAttribute.SaveName + : $"{saveableFieldAttribute.SaveName}.json"; + + string saveDataPath = Path.Combine(folderPath, filename); + if (!File.Exists(saveDataPath)) + continue; + + string json = File.ReadAllText(saveDataPath); + Type type = saveableField.FieldType; + object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); + saveableField.SetValue(this, value); + } + + InvokeOnLoaded(); + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the internal SaveInternal method. + /// Saves all fields marked with the attribute to JSON files in the specified folder. + /// + /// The folder path where save files should be written. + /// Reference to a list of extra saveable files that should not be deleted during cleanup. + void ISaveable.SaveInternal(string folderPath, ref List extraSaveables) => + SaveInternal(folderPath, ref extraSaveables); + + /// + /// INTERNAL: Saves all fields marked with the attribute to JSON files in the specified folder. + /// This method uses reflection to find fields with the SaveableField attribute and serializes their values to JSON files. + /// Null fields result in their corresponding save files being deleted. Non-null fields are added to the extraSaveables list + /// to prevent the base game from deleting them during cleanup. After saving all fields, it calls the method + /// to allow derived classes to perform additional finalization. + /// + /// The folder path where save files should be written. + /// Reference to a list of extra saveable files that should not be deleted during cleanup. + internal virtual void SaveInternal(string folderPath, ref List extraSaveables) + { + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + string saveFileName = saveableFieldAttribute.SaveName.EndsWith(".json") + ? saveableFieldAttribute.SaveName + : $"{saveableFieldAttribute.SaveName}.json"; + + string saveDataPath = Path.Combine(folderPath, saveFileName); + + object? value = saveableField.GetValue(this); + if (value == null) + // Remove the save if the field is null + File.Delete(saveDataPath); + else + { + // We add this to the extra saveables to prevent the game from deleting it + // Otherwise, it'll delete it after it finishes saving and does clean up + extraSaveables.Add(saveFileName); + + // Write our data + string data = JsonConvert.SerializeObject(value, Formatting.Indented, ISaveable.SerializerSettings); + File.WriteAllText(saveDataPath, data); + } + } + + InvokeOnSaved(); + } + + /// + /// INTERNAL: Writes fields marked with into a DynamicSaveData blob + /// to support the base game's consolidated JSON save format. + /// + /// The dynamic save data record to write into. + internal void SaveToDynamic(S1Datas.DynamicSaveData dynamicSaveData) + { + if (dynamicSaveData == null) + return; + + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + object? value = saveableField.GetValue(this); + if (value == null) + continue; // Do not write nulls + + string data = JsonConvert.SerializeObject(value, Formatting.None, ISaveable.SerializerSettings); + // Use the declared save name as the dynamic key + dynamicSaveData.AddData(saveableFieldAttribute.SaveName, data); + } + + InvokeOnSaved(); + } + + /// + /// INTERNAL: Reads fields marked with from a DynamicSaveData blob + /// to support the base game's consolidated JSON save format. + /// + /// The dynamic save data record to read from. + internal void LoadFromDynamic(S1Datas.DynamicSaveData dynamicSaveData) + { + if (dynamicSaveData == null) + return; + + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + // Read the raw json for this save name and deserialize to the field type + if (!dynamicSaveData.TryGetData(saveableFieldAttribute.SaveName, out string json) || string.IsNullOrEmpty(json)) + continue; + + Type type = saveableField.FieldType; + object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); + saveableField.SetValue(this, value); + } + + InvokeOnLoaded(); + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the virtual OnLoaded method. + /// Called after all saveable fields have been loaded from their respective JSON files. + /// + void ISaveable.OnLoaded() => InvokeOnLoaded(); + + /// + /// Called after all saveable fields have been loaded from their respective JSON files. + /// This method can be overridden in derived classes to perform additional initialization + /// or processing after the save data has been restored. + /// + protected virtual void OnLoaded() { } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the virtual OnSaved method. + /// Called after all saveable fields have been saved to their respective JSON files. + /// + void ISaveable.OnSaved() => InvokeOnSaved(); + + /// + /// Called after all saveable fields have been saved to their respective JSON files. + /// This method can be overridden in derived classes to perform additional finalization + /// or processing after the save data has been written to disk. + /// + protected virtual void OnSaved() { } + } +} diff --git a/S1API/Saveables/SaveableAutoRegistry.cs b/S1API/Saveables/SaveableAutoRegistry.cs index b22a7bfa..76a1e212 100644 --- a/S1API/Saveables/SaveableAutoRegistry.cs +++ b/S1API/Saveables/SaveableAutoRegistry.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using S1API.Internal.Abstraction; using S1API.Internal.Utils; namespace S1API.Saveables @@ -132,7 +131,10 @@ private static bool IsDirectSaveableInheritor(Type type) // Check if the base type is exactly Saveable (not a subclass) Type? baseType = type.BaseType; - return baseType == typeof(Saveable); +#pragma warning disable CS0618 // Keep discovery of existing mods using the compatibility base. + return baseType == typeof(Saveable) + || baseType == typeof(global::S1API.Internal.Abstraction.Saveable); +#pragma warning restore CS0618 } /// diff --git a/S1API/docs/getting-started.md b/S1API/docs/getting-started.md index 80117a08..4c8f01cf 100644 --- a/S1API/docs/getting-started.md +++ b/S1API/docs/getting-started.md @@ -113,7 +113,6 @@ Launch the game. Your app icon should appear on the phone; clicking it opens you Annotate fields with `SaveableField` in classes inheriting `Saveable`. S1API will save/load JSON per save slot. ```csharp -using S1API.Internal.Abstraction; using S1API.Saveables; public class MySaveData : Saveable diff --git a/S1API/docs/save-system.md b/S1API/docs/save-system.md index 0c5b4d37..ba3e5521 100644 --- a/S1API/docs/save-system.md +++ b/S1API/docs/save-system.md @@ -1,11 +1,10 @@ # Save System -S1API provides a simple, attribute-based persistence model. Inherit from `Internal.Abstraction.Saveable` and annotate fields with `SaveableField`. +S1API provides a simple, attribute-based persistence model. Inherit from `S1API.Saveables.Saveable` and annotate fields with `SaveableField`. ## Basics ```csharp -using S1API.Internal.Abstraction; using S1API.Saveables; public class NotesSave : Saveable @@ -26,6 +25,14 @@ public class NotesSave : Saveable S1API uses JSON with standard `JsonSerializerSettings` that ignore reference loops and support GUID references. +## Migration from the internal namespace + +`S1API.Internal.Abstraction.Saveable` is obsolete with a compiler warning. Existing compiled mods continue to use the compatibility base, which forwards to `S1API.Saveables.Saveable`. Its removal is planned for a future breaking release; no removal version has been set. + +When updating a standalone saveable, replace the old namespace import with `using S1API.Saveables;`. If you need both namespaces, qualify the base as `S1API.Saveables.Saveable` or use an alias to avoid an ambiguous `Saveable` reference. Keep your class name, namespace, and `SaveableField` keys unchanged: changing the base alone does not change existing save folders or JSON keys. + +NPC and quest subclasses continue to inherit their persistence support from their existing API base classes and need no migration. + ## Load Order Control By default, saveables load **after** base game ISaveables (NPCs, buildings, vehicles, etc.). Override the `LoadOrder` property to control timing: From 50c720821188b47ad40d5148ccbff36305f0d702 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 1 Oct 2026 22:35:28 -0700 Subject: [PATCH 2/2] docs(abstraction): retain only registerable and saveable documentation --- .../Saveables/SaveableCompatibilityTests.cs | 171 ---------- S1API/Entities/NPC.cs | 4 +- S1API/Internal/Abstraction/Registerable.cs | 7 +- S1API/Internal/Abstraction/Saveable.cs | 291 ++++++++++++++++-- S1API/Quests/Quest.cs | 6 +- S1API/Saveables/Saveable.cs | 290 ----------------- S1API/Saveables/SaveableAutoRegistry.cs | 6 +- S1API/docs/getting-started.md | 1 + S1API/docs/save-system.md | 11 +- 9 files changed, 280 insertions(+), 507 deletions(-) delete mode 100644 S1API.Tests/Saveables/SaveableCompatibilityTests.cs delete mode 100644 S1API/Saveables/Saveable.cs diff --git a/S1API.Tests/Saveables/SaveableCompatibilityTests.cs b/S1API.Tests/Saveables/SaveableCompatibilityTests.cs deleted file mode 100644 index 6a638f88..00000000 --- a/S1API.Tests/Saveables/SaveableCompatibilityTests.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.ComponentModel; -using System.Reflection; -using S1API.Internal.Abstraction; -using S1API.Saveables; -using Saveable = S1API.Saveables.Saveable; -#pragma warning disable CS0618 // These fixtures intentionally exercise the legacy API. -using LegacySaveable = S1API.Internal.Abstraction.Saveable; - -namespace S1API.Tests.Saveables; - -public sealed class SaveableCompatibilityTests -{ - [Fact] - public void LegacySurfaceRemainsAvailableWithWarningOnlyDeprecation() - { - ObsoleteAttribute obsolete = typeof(LegacySaveable).GetCustomAttribute()!; - Assert.False(obsolete.IsError); - Assert.Contains("S1API.Saveables.Saveable", obsolete.Message); - Assert.Null(typeof(Saveable).GetCustomAttribute()); - Assert.Equal(EditorBrowsableState.Always, typeof(Saveable).GetCustomAttribute()!.State); - Assert.True(typeof(Saveable).IsAssignableFrom(typeof(LegacySaveable))); - Assert.Equal(typeof(LegacySaveable), typeof(global::S1API.Entities.NPC).BaseType); - Assert.Equal(typeof(LegacySaveable), typeof(global::S1API.Quests.Quest).BaseType); - - const BindingFlags declared = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; - Assert.True(typeof(LegacySaveable).GetProperty("LoadOrder", declared)!.GetMethod!.IsVirtual); - foreach (string hook in new[] { "OnLoaded", "OnSaved" }) - { - MethodInfo method = typeof(LegacySaveable).GetMethod(hook, declared)!; - Assert.True(method.IsFamily); - Assert.True(method.IsVirtual); - Assert.False(method.IsFinal); - Assert.True((method.Attributes & MethodAttributes.NewSlot) != 0); - } - Assert.NotNull(typeof(LegacySaveable).GetMethod("RequestGameSave", declared, Type.EmptyTypes)); - MethodInfo request = typeof(LegacySaveable).GetMethod("RequestGameSave", declared, new[] { typeof(bool) })!; - Assert.Equal("immediate", request.GetParameters()[0].Name); - Assert.True(typeof(Registerable).IsPublic); - Assert.Equal(EditorBrowsableState.Never, typeof(Registerable).GetCustomAttribute()!.State); - } - - [Fact] - public void DiscoveryAcceptsBothDirectBasesAndKeepsIndirectClassesExcluded() - { - MethodInfo discover = typeof(SaveableAutoRegistry).GetMethod("IsDirectSaveableInheritor", BindingFlags.NonPublic | BindingFlags.Static)!; - bool Accepts(Type type) => (bool)discover.Invoke(null, new object[] { type })!; - - Assert.True(Accepts(typeof(CurrentFixture))); - Assert.True(Accepts(typeof(LegacyFixture))); - Assert.False(Accepts(typeof(IndirectCurrentFixture))); - Assert.False(Accepts(typeof(IndirectLegacyFixture))); - Assert.False(Accepts(typeof(Saveable))); - Assert.False(Accepts(typeof(LegacySaveable))); - Assert.False(Accepts(typeof(global::S1API.Entities.NPC))); - Assert.False(Accepts(typeof(global::S1API.Quests.Quest))); - Assert.False(Accepts(typeof(string))); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void LifecycleAndLegacyJsonLoadingDispatchThroughBothBases(bool legacy) - { - Saveable instance = legacy ? new LegacyFixture() : new CurrentFixture(); - IFixture fixture = (IFixture)instance; - Assert.Equal(SaveableLoadOrder.AfterBaseGame, instance.LoadOrder); - fixture.LoadBeforeBaseGame = true; - Assert.Equal(SaveableLoadOrder.BeforeBaseGame, instance.LoadOrder); - string folder = Path.Combine(Path.GetTempPath(), "S1API-Saveable-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(folder); - try - { - ((IRegisterable)instance).CreateInternal(); - ((ISaveable)instance).LoadInternal(folder); - Assert.Equal(1, fixture.Loaded); - Assert.Equal(7, fixture.Value); - File.WriteAllText(Path.Combine(folder, "value.json"), "42"); - ((ISaveable)instance).LoadInternal(folder); - Assert.Equal(42, fixture.Value); - Assert.Equal(2, fixture.Loaded); - ((ISaveable)instance).OnSaved(); - ((IRegisterable)instance).DestroyInternal(); - Assert.Equal(1, fixture.Saved); - Assert.Equal(1, fixture.Created); - Assert.Equal(1, fixture.Destroyed); - } - finally - { - Directory.Delete(folder, true); - } - } - -#if MONOMELON - [Theory] - [InlineData(false)] - [InlineData(true)] - public void SavingPreservesFieldNamesAndNullDeletion(bool legacy) - { - Saveable instance = legacy ? new LegacyFixture() : new CurrentFixture(); - IFixture fixture = (IFixture)instance; - string folder = Path.Combine(Path.GetTempPath(), "S1API-Saveable-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(folder); - try - { - File.WriteAllText(Path.Combine(folder, "optional.json"), "99"); - var extra = new List(); - ((ISaveable)instance).SaveInternal(folder, ref extra); - Assert.Equal("7", File.ReadAllText(Path.Combine(folder, "value.json"))); - Assert.Equal(new[] { "value.json" }, extra); - Assert.False(File.Exists(Path.Combine(folder, "optional.json"))); - Assert.Null(fixture.Optional); - Assert.Equal(1, fixture.Saved); - } - finally - { - Directory.Delete(folder, true); - } - } -#endif - - private interface IFixture - { - int Value { get; } - int? Optional { get; } - bool LoadBeforeBaseGame { get; set; } - int Created { get; } - int Destroyed { get; } - int Loaded { get; } - int Saved { get; } - } - - private class CurrentFixture : Saveable, IFixture - { - [SaveableField("value")] private int _value = 7; - [SaveableField("optional.json")] private int? _optional = null; - public int Value => _value; - public int? Optional => _optional; - public bool LoadBeforeBaseGame { get; set; } - public override SaveableLoadOrder LoadOrder => LoadBeforeBaseGame ? SaveableLoadOrder.BeforeBaseGame : base.LoadOrder; - public int Created { get; private set; } - public int Destroyed { get; private set; } - public int Loaded { get; private set; } - public int Saved { get; private set; } - protected override void OnCreated() => Created++; - protected override void OnDestroyed() => Destroyed++; - protected override void OnLoaded() { base.OnLoaded(); Loaded++; } - protected override void OnSaved() { base.OnSaved(); Saved++; } - } - - private class LegacyFixture : LegacySaveable, IFixture - { - [SaveableField("value")] private int _value = 7; - [SaveableField("optional.json")] private int? _optional = null; - public int Value => _value; - public int? Optional => _optional; - public bool LoadBeforeBaseGame { get; set; } - public override SaveableLoadOrder LoadOrder => LoadBeforeBaseGame ? SaveableLoadOrder.BeforeBaseGame : base.LoadOrder; - public int Created { get; private set; } - public int Destroyed { get; private set; } - public int Loaded { get; private set; } - public int Saved { get; private set; } - protected override void OnCreated() => Created++; - protected override void OnDestroyed() => Destroyed++; - protected override void OnLoaded() { base.OnLoaded(); Loaded++; } - protected override void OnSaved() { base.OnSaved(); Saved++; } - } - - private sealed class IndirectCurrentFixture : CurrentFixture { } - private sealed class IndirectLegacyFixture : LegacyFixture { } -} -#pragma warning restore CS0618 diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 59c0b375..02fb1783 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -125,9 +125,7 @@ namespace S1API.Entities /// , , , and . /// Customer, relationship, and schedule configuration must be done in for proper save/load behavior. /// -#pragma warning disable CS0618 // Preserve the legacy base type for existing NPC mods. - public abstract class NPC : global::S1API.Internal.Abstraction.Saveable, IEntity, IHealth -#pragma warning restore CS0618 + public abstract class NPC : Saveable, IEntity, IHealth { private static readonly Log Logger = new Log("NPC"); // Protected members intended to be used by modders. diff --git a/S1API/Internal/Abstraction/Registerable.cs b/S1API/Internal/Abstraction/Registerable.cs index a907f811..9383c441 100644 --- a/S1API/Internal/Abstraction/Registerable.cs +++ b/S1API/Internal/Abstraction/Registerable.cs @@ -2,9 +2,12 @@ { /// /// Infrastructure base class for S1API registration and lifecycle hooks. - /// Public so modder-facing API classes can inherit it; not intended for direct modder inheritance. /// - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + /// + /// This class is public because public API classes inherit from it. + /// Modders should inherit from the appropriate API base class, such as + /// NPC, Quest, or Saveable, rather than this infrastructure class directly. + /// public abstract class Registerable : IRegisterable { /// diff --git a/S1API/Internal/Abstraction/Saveable.cs b/S1API/Internal/Abstraction/Saveable.cs index 278a21df..a93ea17d 100644 --- a/S1API/Internal/Abstraction/Saveable.cs +++ b/S1API/Internal/Abstraction/Saveable.cs @@ -1,47 +1,290 @@ +#if MONOMELON +using System.Collections.Generic; +#elif IL2CPPMELON +using Il2CppSystem.Collections.Generic; +#endif + using System; -using System.ComponentModel; +using System.IO; +using System.Reflection; +using Newtonsoft.Json; +using S1API.Internal.Utils; using S1API.Saveables; +#if (IL2CPPMELON) +using S1Datas = Il2CppScheduleOne.Persistence.Datas; +#elif MONOMELON +using S1Datas = ScheduleOne.Persistence.Datas; +#endif + +#if (IL2CPPMELON) +using S1Persistence = Il2CppScheduleOne.Persistence; +#elif MONOMELON +using S1Persistence = ScheduleOne.Persistence; +#endif namespace S1API.Internal.Abstraction { /// - /// Compatibility base class for existing mods. Use - /// for new saveable classes. + /// Base class for mod data persisted in the game's save slots. /// /// - /// Retained for existing compiled mods and scheduled for removal in a future breaking release. - /// No removal version has been set. + /// This is a supported modder-facing API despite its S1API.Internal.Abstraction namespace. + /// Inherit from this class for standalone mod save data and mark fields with + /// . Override and + /// to respond to persistence events, and to control load timing. + /// API classes such as NPC and Quest already inherit this persistence support. /// - [Obsolete("Use S1API.Saveables.Saveable instead. This compatibility type will be removed in a future breaking release.", false)] - [EditorBrowsable(EditorBrowsableState.Never)] - public abstract class Saveable : global::S1API.Saveables.Saveable + public abstract class Saveable : Registerable, ISaveable { - /// - public new virtual SaveableLoadOrder LoadOrder => SaveableLoadOrder.AfterBaseGame; + /// + /// Determines when this saveable should load relative to base game saveables. + /// + /// + /// Default is , which loads after base game entities are loaded. + /// Override this property to return if your mod data needs to be + /// available before the base game's ISaveables are loaded. + /// + /// + /// + /// AfterBaseGame (default): Your method is called after base game entities + /// (NPCs, buildings, vehicles) have been loaded. This is the recommended setting for most mods. + /// + /// + /// BeforeBaseGame: Your method is called before base game entities are loaded. + /// Use this only if you need to set up hooks or state that the base game loading process depends on. + /// + /// + /// Note: All saveables are saved at the same time (after base game save), regardless of load order. + /// + /// + /// + /// + /// public class EarlyConfigSaveable : Saveable + /// { + /// public override SaveableLoadOrder LoadOrder => SaveableLoadOrder.BeforeBaseGame; + /// + /// [SaveableField("config")] + /// private ModConfig _config = new ModConfig(); + /// + /// protected override void OnLoaded() + /// { + /// // Base game entities are NOT loaded yet + /// ApplyGlobalSettings(_config); + /// } + /// } + /// + /// + public virtual SaveableLoadOrder LoadOrder => SaveableLoadOrder.AfterBaseGame; /// - /// Requests a game save. The immediate parameter is retained for compatibility and ignored. + /// Requests the game to perform a save operation. If a game is not currently loaded, + /// the request is ignored and the method returns false. /// - /// Ignored, as in the original implementation. + /// This parameter is ignored in v0.4.3+ (kept for backwards compatibility). /// True if a save was requested; false if the game is not in a savable state. - public new static bool RequestGameSave(bool immediate) => - global::S1API.Saveables.Saveable.RequestGameSave(immediate); + public static bool RequestGameSave(bool immediate) => RequestGameSave(); /// - /// Requests a game save when a game is loaded. + /// Requests the game to perform a save operation. If a game is not currently loaded, + /// the request is ignored and the method returns false. /// /// True if a save was requested; false if the game is not in a savable state. - public new static bool RequestGameSave() => global::S1API.Saveables.Saveable.RequestGameSave(); + public static bool RequestGameSave() + { + try + { + var loadManager = S1Persistence.LoadManager.Instance; + if (loadManager == null || !loadManager.IsGameLoaded) + return false; + + var saveManager = S1Persistence.SaveManager.Instance; + if (saveManager == null) + return false; + + saveManager.Save(); + + return true; + } + catch + { + return false; + } + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the internal LoadInternal method. + /// Loads all fields marked with the attribute from JSON files in the specified folder. + /// + /// The folder path containing the save files to load. + void ISaveable.LoadInternal(string folderPath) => + LoadInternal(folderPath); + + /// + /// INTERNAL: Loads all fields marked with the attribute from JSON files in the specified folder. + /// This method uses reflection to find fields with the SaveableField attribute and deserializes their values from JSON files. + /// After loading all fields, it calls the method to allow derived classes to perform additional initialization. + /// + /// The folder path containing the save files to load from. + internal virtual void LoadInternal(string folderPath) + { + FieldInfo[] saveableFields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + string filename = saveableFieldAttribute.SaveName.EndsWith(".json") + ? saveableFieldAttribute.SaveName + : $"{saveableFieldAttribute.SaveName}.json"; + + string saveDataPath = Path.Combine(folderPath, filename); + if (!File.Exists(saveDataPath)) + continue; + + string json = File.ReadAllText(saveDataPath); + Type type = saveableField.FieldType; + object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); + saveableField.SetValue(this, value); + } + + OnLoaded(); + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the internal SaveInternal method. + /// Saves all fields marked with the attribute to JSON files in the specified folder. + /// + /// The folder path where save files should be written. + /// Reference to a list of extra saveable files that should not be deleted during cleanup. + void ISaveable.SaveInternal(string folderPath, ref List extraSaveables) => + SaveInternal(folderPath, ref extraSaveables); + + /// + /// INTERNAL: Saves all fields marked with the attribute to JSON files in the specified folder. + /// This method uses reflection to find fields with the SaveableField attribute and serializes their values to JSON files. + /// Null fields result in their corresponding save files being deleted. Non-null fields are added to the extraSaveables list + /// to prevent the base game from deleting them during cleanup. After saving all fields, it calls the method + /// to allow derived classes to perform additional finalization. + /// + /// The folder path where save files should be written. + /// Reference to a list of extra saveable files that should not be deleted during cleanup. + internal virtual void SaveInternal(string folderPath, ref List extraSaveables) + { + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + string saveFileName = saveableFieldAttribute.SaveName.EndsWith(".json") + ? saveableFieldAttribute.SaveName + : $"{saveableFieldAttribute.SaveName}.json"; + + string saveDataPath = Path.Combine(folderPath, saveFileName); + + object? value = saveableField.GetValue(this); + if (value == null) + // Remove the save if the field is null + File.Delete(saveDataPath); + else + { + // We add this to the extra saveables to prevent the game from deleting it + // Otherwise, it'll delete it after it finishes saving and does clean up + extraSaveables.Add(saveFileName); - /// - protected new virtual void OnLoaded() => base.OnLoaded(); + // Write our data + string data = JsonConvert.SerializeObject(value, Formatting.Indented, ISaveable.SerializerSettings); + File.WriteAllText(saveDataPath, data); + } + } - /// - protected new virtual void OnSaved() => base.OnSaved(); + OnSaved(); + } - // Keep the original virtual slots for already compiled mod overrides. - internal override SaveableLoadOrder GetLoadOrder() => LoadOrder; - internal override void InvokeOnLoaded() => OnLoaded(); - internal override void InvokeOnSaved() => OnSaved(); + /// + /// INTERNAL: Writes fields marked with into a DynamicSaveData blob + /// to support the base game's consolidated JSON save format. + /// + /// The dynamic save data record to write into. + internal void SaveToDynamic(S1Datas.DynamicSaveData dynamicSaveData) + { + if (dynamicSaveData == null) + return; + + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + object? value = saveableField.GetValue(this); + if (value == null) + continue; // Do not write nulls + + string data = JsonConvert.SerializeObject(value, Formatting.None, ISaveable.SerializerSettings); + // Use the declared save name as the dynamic key + dynamicSaveData.AddData(saveableFieldAttribute.SaveName, data); + } + + OnSaved(); + } + + /// + /// INTERNAL: Reads fields marked with from a DynamicSaveData blob + /// to support the base game's consolidated JSON save format. + /// + /// The dynamic save data record to read from. + internal void LoadFromDynamic(S1Datas.DynamicSaveData dynamicSaveData) + { + if (dynamicSaveData == null) + return; + + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + // Read the raw json for this save name and deserialize to the field type + if (!dynamicSaveData.TryGetData(saveableFieldAttribute.SaveName, out string json) || string.IsNullOrEmpty(json)) + continue; + + Type type = saveableField.FieldType; + object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); + saveableField.SetValue(this, value); + } + + OnLoaded(); + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the virtual OnLoaded method. + /// Called after all saveable fields have been loaded from their respective JSON files. + /// + void ISaveable.OnLoaded() => OnLoaded(); + + /// + /// Called after all saveable fields have been loaded from their respective JSON files. + /// This method can be overridden in derived classes to perform additional initialization + /// or processing after the save data has been restored. + /// + protected virtual void OnLoaded() { } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the virtual OnSaved method. + /// Called after all saveable fields have been saved to their respective JSON files. + /// + void ISaveable.OnSaved() => OnSaved(); + + /// + /// Called after all saveable fields have been saved to their respective JSON files. + /// This method can be overridden in derived classes to perform additional finalization + /// or processing after the save data has been written to disk. + /// + protected virtual void OnSaved() { } } } diff --git a/S1API/Quests/Quest.cs b/S1API/Quests/Quest.cs index e10281f3..fbbefed7 100644 --- a/S1API/Quests/Quest.cs +++ b/S1API/Quests/Quest.cs @@ -42,10 +42,8 @@ namespace S1API.Quests /// /// To create a quest, you must use to instantiate your quest. /// - /// -#pragma warning disable CS0618 // Preserve the legacy base type for existing quest mods. - public abstract class Quest : global::S1API.Internal.Abstraction.Saveable -#pragma warning restore CS0618 + /// + public abstract class Quest : Saveable { /// /// The title of the quest to display for the player. diff --git a/S1API/Saveables/Saveable.cs b/S1API/Saveables/Saveable.cs deleted file mode 100644 index e081753d..00000000 --- a/S1API/Saveables/Saveable.cs +++ /dev/null @@ -1,290 +0,0 @@ -#if MONOMELON -using System.Collections.Generic; -#elif IL2CPPMELON -using Il2CppSystem.Collections.Generic; -#endif - -using System; -using System.ComponentModel; -using System.IO; -using System.Reflection; -using Newtonsoft.Json; -using S1API.Internal.Utils; -using S1API.Internal.Abstraction; -#if (IL2CPPMELON) -using S1Datas = Il2CppScheduleOne.Persistence.Datas; -#elif MONOMELON -using S1Datas = ScheduleOne.Persistence.Datas; -#endif - -#if (IL2CPPMELON) -using S1Persistence = Il2CppScheduleOne.Persistence; -#elif MONOMELON -using S1Persistence = ScheduleOne.Persistence; -#endif - -namespace S1API.Saveables -{ - /// - /// Base class for mod data persisted in each game save slot. - /// - [EditorBrowsable(EditorBrowsableState.Always)] - public abstract class Saveable : Registerable, ISaveable - { - /// - /// Determines when this saveable should load relative to base game saveables. - /// - /// - /// Default is , which loads after base game entities are loaded. - /// Override this property to return if your mod data needs to be - /// available before the base game's ISaveables are loaded. - /// - /// - /// - /// AfterBaseGame (default): Your method is called after base game entities - /// (NPCs, buildings, vehicles) have been loaded. This is the recommended setting for most mods. - /// - /// - /// BeforeBaseGame: Your method is called before base game entities are loaded. - /// Use this only if you need to set up hooks or state that the base game loading process depends on. - /// - /// - /// Note: All saveables are saved at the same time (after base game save), regardless of load order. - /// - /// - /// - /// - /// public class EarlyConfigSaveable : Saveable - /// { - /// public override SaveableLoadOrder LoadOrder => SaveableLoadOrder.BeforeBaseGame; - /// - /// [SaveableField("config")] - /// private ModConfig _config = new ModConfig(); - /// - /// protected override void OnLoaded() - /// { - /// // Base game entities are NOT loaded yet - /// ApplyGlobalSettings(_config); - /// } - /// } - /// - /// - public virtual SaveableLoadOrder LoadOrder => GetLoadOrder(); - - // Dispatch through the legacy virtual slots when a compatibility subclass is used. - internal virtual SaveableLoadOrder GetLoadOrder() => SaveableLoadOrder.AfterBaseGame; - internal virtual void InvokeOnLoaded() => OnLoaded(); - internal virtual void InvokeOnSaved() => OnSaved(); - - /// - /// Requests the game to perform a save operation. If a game is not currently loaded, - /// the request is ignored and the method returns false. - /// - /// This parameter is ignored in v0.4.3+ (kept for backwards compatibility). - /// True if a save was requested; false if the game is not in a savable state. - public static bool RequestGameSave(bool immediate) => RequestGameSave(); - - /// - /// Requests the game to perform a save operation. If a game is not currently loaded, - /// the request is ignored and the method returns false. - /// - /// True if a save was requested; false if the game is not in a savable state. - public static bool RequestGameSave() - { - try - { - var loadManager = S1Persistence.LoadManager.Instance; - if (loadManager == null || !loadManager.IsGameLoaded) - return false; - - var saveManager = S1Persistence.SaveManager.Instance; - if (saveManager == null) - return false; - - saveManager.Save(); - - return true; - } - catch - { - return false; - } - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the internal LoadInternal method. - /// Loads all fields marked with the attribute from JSON files in the specified folder. - /// - /// The folder path containing the save files to load. - void ISaveable.LoadInternal(string folderPath) => - LoadInternal(folderPath); - - /// - /// INTERNAL: Loads all fields marked with the attribute from JSON files in the specified folder. - /// This method uses reflection to find fields with the SaveableField attribute and deserializes their values from JSON files. - /// After loading all fields, it calls the method to allow derived classes to perform additional initialization. - /// - /// The folder path containing the save files to load from. - internal virtual void LoadInternal(string folderPath) - { - FieldInfo[] saveableFields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - string filename = saveableFieldAttribute.SaveName.EndsWith(".json") - ? saveableFieldAttribute.SaveName - : $"{saveableFieldAttribute.SaveName}.json"; - - string saveDataPath = Path.Combine(folderPath, filename); - if (!File.Exists(saveDataPath)) - continue; - - string json = File.ReadAllText(saveDataPath); - Type type = saveableField.FieldType; - object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); - saveableField.SetValue(this, value); - } - - InvokeOnLoaded(); - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the internal SaveInternal method. - /// Saves all fields marked with the attribute to JSON files in the specified folder. - /// - /// The folder path where save files should be written. - /// Reference to a list of extra saveable files that should not be deleted during cleanup. - void ISaveable.SaveInternal(string folderPath, ref List extraSaveables) => - SaveInternal(folderPath, ref extraSaveables); - - /// - /// INTERNAL: Saves all fields marked with the attribute to JSON files in the specified folder. - /// This method uses reflection to find fields with the SaveableField attribute and serializes their values to JSON files. - /// Null fields result in their corresponding save files being deleted. Non-null fields are added to the extraSaveables list - /// to prevent the base game from deleting them during cleanup. After saving all fields, it calls the method - /// to allow derived classes to perform additional finalization. - /// - /// The folder path where save files should be written. - /// Reference to a list of extra saveable files that should not be deleted during cleanup. - internal virtual void SaveInternal(string folderPath, ref List extraSaveables) - { - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - string saveFileName = saveableFieldAttribute.SaveName.EndsWith(".json") - ? saveableFieldAttribute.SaveName - : $"{saveableFieldAttribute.SaveName}.json"; - - string saveDataPath = Path.Combine(folderPath, saveFileName); - - object? value = saveableField.GetValue(this); - if (value == null) - // Remove the save if the field is null - File.Delete(saveDataPath); - else - { - // We add this to the extra saveables to prevent the game from deleting it - // Otherwise, it'll delete it after it finishes saving and does clean up - extraSaveables.Add(saveFileName); - - // Write our data - string data = JsonConvert.SerializeObject(value, Formatting.Indented, ISaveable.SerializerSettings); - File.WriteAllText(saveDataPath, data); - } - } - - InvokeOnSaved(); - } - - /// - /// INTERNAL: Writes fields marked with into a DynamicSaveData blob - /// to support the base game's consolidated JSON save format. - /// - /// The dynamic save data record to write into. - internal void SaveToDynamic(S1Datas.DynamicSaveData dynamicSaveData) - { - if (dynamicSaveData == null) - return; - - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - object? value = saveableField.GetValue(this); - if (value == null) - continue; // Do not write nulls - - string data = JsonConvert.SerializeObject(value, Formatting.None, ISaveable.SerializerSettings); - // Use the declared save name as the dynamic key - dynamicSaveData.AddData(saveableFieldAttribute.SaveName, data); - } - - InvokeOnSaved(); - } - - /// - /// INTERNAL: Reads fields marked with from a DynamicSaveData blob - /// to support the base game's consolidated JSON save format. - /// - /// The dynamic save data record to read from. - internal void LoadFromDynamic(S1Datas.DynamicSaveData dynamicSaveData) - { - if (dynamicSaveData == null) - return; - - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - // Read the raw json for this save name and deserialize to the field type - if (!dynamicSaveData.TryGetData(saveableFieldAttribute.SaveName, out string json) || string.IsNullOrEmpty(json)) - continue; - - Type type = saveableField.FieldType; - object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); - saveableField.SetValue(this, value); - } - - InvokeOnLoaded(); - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the virtual OnLoaded method. - /// Called after all saveable fields have been loaded from their respective JSON files. - /// - void ISaveable.OnLoaded() => InvokeOnLoaded(); - - /// - /// Called after all saveable fields have been loaded from their respective JSON files. - /// This method can be overridden in derived classes to perform additional initialization - /// or processing after the save data has been restored. - /// - protected virtual void OnLoaded() { } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the virtual OnSaved method. - /// Called after all saveable fields have been saved to their respective JSON files. - /// - void ISaveable.OnSaved() => InvokeOnSaved(); - - /// - /// Called after all saveable fields have been saved to their respective JSON files. - /// This method can be overridden in derived classes to perform additional finalization - /// or processing after the save data has been written to disk. - /// - protected virtual void OnSaved() { } - } -} diff --git a/S1API/Saveables/SaveableAutoRegistry.cs b/S1API/Saveables/SaveableAutoRegistry.cs index 76a1e212..b22a7bfa 100644 --- a/S1API/Saveables/SaveableAutoRegistry.cs +++ b/S1API/Saveables/SaveableAutoRegistry.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using S1API.Internal.Abstraction; using S1API.Internal.Utils; namespace S1API.Saveables @@ -131,10 +132,7 @@ private static bool IsDirectSaveableInheritor(Type type) // Check if the base type is exactly Saveable (not a subclass) Type? baseType = type.BaseType; -#pragma warning disable CS0618 // Keep discovery of existing mods using the compatibility base. - return baseType == typeof(Saveable) - || baseType == typeof(global::S1API.Internal.Abstraction.Saveable); -#pragma warning restore CS0618 + return baseType == typeof(Saveable); } /// diff --git a/S1API/docs/getting-started.md b/S1API/docs/getting-started.md index 4c8f01cf..80117a08 100644 --- a/S1API/docs/getting-started.md +++ b/S1API/docs/getting-started.md @@ -113,6 +113,7 @@ Launch the game. Your app icon should appear on the phone; clicking it opens you Annotate fields with `SaveableField` in classes inheriting `Saveable`. S1API will save/load JSON per save slot. ```csharp +using S1API.Internal.Abstraction; using S1API.Saveables; public class MySaveData : Saveable diff --git a/S1API/docs/save-system.md b/S1API/docs/save-system.md index ba3e5521..0c5b4d37 100644 --- a/S1API/docs/save-system.md +++ b/S1API/docs/save-system.md @@ -1,10 +1,11 @@ # Save System -S1API provides a simple, attribute-based persistence model. Inherit from `S1API.Saveables.Saveable` and annotate fields with `SaveableField`. +S1API provides a simple, attribute-based persistence model. Inherit from `Internal.Abstraction.Saveable` and annotate fields with `SaveableField`. ## Basics ```csharp +using S1API.Internal.Abstraction; using S1API.Saveables; public class NotesSave : Saveable @@ -25,14 +26,6 @@ public class NotesSave : Saveable S1API uses JSON with standard `JsonSerializerSettings` that ignore reference loops and support GUID references. -## Migration from the internal namespace - -`S1API.Internal.Abstraction.Saveable` is obsolete with a compiler warning. Existing compiled mods continue to use the compatibility base, which forwards to `S1API.Saveables.Saveable`. Its removal is planned for a future breaking release; no removal version has been set. - -When updating a standalone saveable, replace the old namespace import with `using S1API.Saveables;`. If you need both namespaces, qualify the base as `S1API.Saveables.Saveable` or use an alias to avoid an ambiguous `Saveable` reference. Keep your class name, namespace, and `SaveableField` keys unchanged: changing the base alone does not change existing save folders or JSON keys. - -NPC and quest subclasses continue to inherit their persistence support from their existing API base classes and need no migration. - ## Load Order Control By default, saveables load **after** base game ISaveables (NPCs, buildings, vehicles, etc.). Override the `LoadOrder` property to control timing: