Skip to content
96 changes: 74 additions & 22 deletions S1API/Internal/Patches/QuestPatches.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
#if (IL2CPPMELON)
#if (IL2CPPMELON)
using S1Loaders = Il2CppScheduleOne.Persistence.Loaders;
using S1Datas = Il2CppScheduleOne.Persistence.Datas;
using S1Quests = Il2CppScheduleOne.Quests;
using S1Persistence = Il2CppScheduleOne.Persistence;
#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
using S1Loaders = ScheduleOne.Persistence.Loaders;
using S1Datas = ScheduleOne.Persistence.Datas;
using S1Quests = ScheduleOne.Quests;
using S1Persistence = ScheduleOne.Persistence;
#endif

#if (IL2CPPMELON || IL2CPPBEPINEX)
using Il2CppSystem.Collections.Generic;
#elif (MONOMELON || MONOBEPINEX)
Expand All @@ -19,63 +20,108 @@
using System.Linq;
using HarmonyLib;
using Newtonsoft.Json;
using S1API.Internal.Abstraction;
using S1API.Internal.Utils;
using S1API.Quests;
using UnityEngine;
using ISaveable = S1API.Internal.Abstraction.ISaveable;

namespace S1API.Internal.Patches
{
/// <summary>
/// INTERNAL: All patches related to quests.
/// INTERNAL: Contains patches related to quest processing and custom modifications.
/// </summary>
[HarmonyPatch]
internal class QuestPatches
{
/// <summary>
/// Patching performed when all quests are saved.
/// Provides a centralized logging mechanism to capture and output messages, warnings,
/// and errors during runtime, using underlying logging frameworks like BepInEx or MelonLoader.
/// </summary>
/// <param name="__instance">Instance of the quest manager.</param>
/// <param name="parentFolderPath">Path to the base Quest folder.</param>
/// <param name="__result">List of extra saveable data. The game uses this for cleanup later.</param>
[HarmonyPatch(typeof(S1Quests.QuestManager), "WriteData")]
protected static readonly Logging.Log Logger = new Logging.Log("QuestPatches");

/// <summary>
/// Executes additional logic after quests are saved by the SaveManager.
/// Ensures that directories for modded quests are properly created and that
/// only non-vanilla modded quests are saved into the specified folder.
/// </summary>
/// <param name="saveFolderPath">The path to the save folder where quests are being stored.</param>
[HarmonyPatch(typeof(S1Persistence.SaveManager), nameof(S1Persistence.SaveManager.Save), typeof(string))]
[HarmonyPostfix]
private static void QuestManagerWriteData(S1Quests.QuestManager __instance, string parentFolderPath, ref List<string> __result)
private static void SaveManager_Save_Postfix(string saveFolderPath)
{
string questsPath = Path.Combine(parentFolderPath, "Quests");
try
{
var saveManager = S1Persistence.SaveManager.Instance;

foreach (Quest quest in QuestManager.Quests)
quest.SaveInternal(questsPath, ref __result);
string[] approved = {
"Modded",
Path.Combine("Modded", "Quests")
};

foreach (var path in approved)
{
if (!saveManager.ApprovedBaseLevelPaths.Contains(path))
saveManager.ApprovedBaseLevelPaths.Add(path);
}

// ✅ Create the directory structure
string questsPath = Path.Combine(saveFolderPath, "Modded", "Quests");
Directory.CreateDirectory(questsPath);

// ✅ Save only non-vanilla modded quests
foreach (Quest quest in QuestManager.Quests)
{
if (!quest.GetType().Namespace.StartsWith("ScheduleOne"))

Check warning on line 74 in S1API/Internal/Patches/QuestPatches.cs

View workflow job for this annotation

GitHub Actions / Verify Successful Build

Dereference of a possibly null reference.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Since the foreach loop is only doing this one thing and only against one type of thing, you could simplify it with foreach (Quest quest in QuestManager.Quests.Where(q => !q.GetType().Namespace.StartsWith("ScheduleOne")) to remove the if. This would also mean that you're looping over fewer things.

Nit in this case because I'm sure it's a very small collection, but good to keep in mind for future.

{
List<string> dummy = new List<string>();
quest.SaveInternal(questsPath, ref dummy);
}
}

}
catch (Exception ex)
{
Logger.Error("[S1API] ❌ Failed to save modded quests:\n" + ex);
}
}


/// <summary>
/// Patching performed for when all quests are loaded.
/// Invoked after all base quests are loaded to handle modded quest loading.
/// Loads modded quests from a specific "Modded/Quests" directory and integrates them into the game.
/// </summary>
/// <param name="__instance">Instance of the quest loader.</param>
/// <param name="mainPath">Path to the base Quest folder.</param>
/// <param name="__instance">The quest loader instance responsible for managing quest load operations.</param>
/// <param name="mainPath">The path to the primary quest directory in the base game.</param>
[HarmonyPatch(typeof(S1Loaders.QuestsLoader), "Load")]
[HarmonyPostfix]
private static void QuestsLoaderLoad(S1Loaders.QuestsLoader __instance, string mainPath)
{
// Make sure we have a quests directory (fresh saves don't at this point in runtime)
if (!Directory.Exists(mainPath))
string moddedQuestsPath = Path.Combine(
S1Persistence.LoadManager.Instance.LoadedGameFolderPath,
"Modded", "Quests"
);

if (!Directory.Exists(moddedQuestsPath))
{
Directory.CreateDirectory(moddedQuestsPath);
return;
}

string[] questDirectories = Directory.GetDirectories(mainPath)
string[] questDirectories = Directory.GetDirectories(moddedQuestsPath)
.Select(Path.GetFileName)
.Where(directory => directory != null && directory.StartsWith("Quest_"))
.ToArray()!;
.ToArray();

foreach (string questDirectory in questDirectories)
{
string baseQuestPath = Path.Combine(mainPath, questDirectory);
string baseQuestPath = Path.Combine(moddedQuestsPath, questDirectory);
__instance.TryLoadFile(baseQuestPath, out string questDataText);
if (questDataText == null)
continue;

S1Datas.QuestData baseQuestData = JsonUtility.FromJson<S1Datas.QuestData>(questDataText);

string questDirectoryPath = Path.Combine(mainPath, questDirectory);
string questDirectoryPath = Path.Combine(moddedQuestsPath, questDirectory);
string questDataPath = Path.Combine(questDirectoryPath, "QuestData");
if (!__instance.TryLoadFile(questDataPath, out string questText))
continue;
Expand All @@ -93,6 +139,12 @@
}
}


/// <summary>
/// Executes logic prior to the start of a quest.
/// Ensures that linked modded quest data is properly initialized.
/// </summary>
/// <param name="__instance">The instance of the quest that is being started.</param>
[HarmonyPatch(typeof(S1Quests.Quest), "Start")]
[HarmonyPrefix]
private static void QuestStart(S1Quests.Quest __instance) =>
Expand Down
23 changes: 18 additions & 5 deletions S1API/Items/ItemDefinition.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#if (IL2CPPMELON)
#if (IL2CPPMELON)
using S1ItemFramework = Il2CppScheduleOne.ItemFramework;
#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
using S1ItemFramework = ScheduleOne.ItemFramework;
Expand Down Expand Up @@ -153,16 +153,29 @@ public override bool Equals(object? obj) =>
/// <param name="a">The first <see cref="ItemDefinition"/> to compare.</param>
/// <param name="b">The second <see cref="ItemDefinition"/> to compare.</param>
/// <returns><c>true</c> if both instances are equal or have the same S1ItemDefinition; otherwise, <c>false</c>.</returns>
public static bool operator ==(ItemDefinition? a, ItemDefinition? b) =>
ReferenceEquals(a, b) || a != null && b != null && a.S1ItemDefinition == b.S1ItemDefinition;

public static bool operator ==(ItemDefinition? a, ItemDefinition? b)
{
if (ReferenceEquals(a, b))
return true;
if (a is null || b is null)
return false;
return ReferenceEquals(a.S1ItemDefinition, b.S1ItemDefinition);
}
/// <summary>
/// Determines whether two <see cref="ItemDefinition"/> instances are not equal.
/// </summary>
/// <param name="a">The first <see cref="ItemDefinition"/> to compare.</param>
/// <param name="b">The second <see cref="ItemDefinition"/> to compare.</param>
/// <returns><c>true</c> if the instances are not equal; otherwise, <c>false</c>.</returns>
public static bool operator !=(ItemDefinition? a, ItemDefinition? b) => !(a == b);
public static bool operator !=(ItemDefinition? a, ItemDefinition? b)
{
if (ReferenceEquals(a, b))
return false;
if (a is null || b is null)
return true;
return !ReferenceEquals(a.S1ItemDefinition, b.S1ItemDefinition);
}

}

/// <summary>
Expand Down
Loading