diff --git a/.gitignore b/.gitignore index c301ca8..bb88804 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,37 @@ -# Godot 4.x -.godot/ -.idea/ -sdk/ -bin/ -obj/ - -#ai -.omo/ -.opencode/ - -gen/ -obj/ -bin/ - -*.tres~ -*.tscn~ - -# GDScript LSP -.godot/ - -# Export -*.exe -*.dmg -*.apk -*.aab -*.pck -*.zip -*.log - -# OS -.DS_Store -Thumbs.db - -# Editor -*.import -*.godot.uid +# Godot 4.x +.godot/ +.idea/ +sdk/ +bin/ +obj/ + +#ai +.omo/ +.opencode/ + +gen/ +obj/ +bin/ + +*.tres~ +*.tscn~ + +# GDScript LSP +.godot/ + +# Export +*.exe +*.dmg +*.apk +*.aab +*.pck +*.zip +*.log + +# OS +.DS_Store +Thumbs.db + +# Editor +*.import +*.godot.uid diff --git a/manifest.json b/manifest.json index c5e6b8d..8d071be 100644 --- a/manifest.json +++ b/manifest.json @@ -8,6 +8,7 @@ "sources": [ "src/**/*.cs" ], + "test_sources": ["tests/*.cs"], "depends": ["characters_base>=1", "ui_base>=1"], "ui": [ { diff --git a/src/Quests/QuestFactory.cs b/src/Quests/QuestFactory.cs index df7b6c4..08b30c4 100644 --- a/src/Quests/QuestFactory.cs +++ b/src/Quests/QuestFactory.cs @@ -16,7 +16,8 @@ namespace Cthangover.Core.Quests /// definitions, on‑demand lookup by ID, and restoration of mutable state /// when the player loads a save file. The factory defers loading until /// the first access (via or ) and - /// guards against re‑initialisation through the isLoaded flag. + /// rebuilds the definitions whenever the entity resolver hands out a new + /// data snapshot (a mod reload keeps this mod but may change the quests). /// Because quest definitions come from /// objects collected by through the /// DTO's [EntityKind("quest")] attribute, the factory remains @@ -50,7 +51,8 @@ namespace Cthangover.Core.Quests public static QuestFactory Instance => instance.Value; private readonly IDictionary dataByID = new Dictionary(); - private bool isLoaded; + private readonly List unresolved = new(); + private Dictionary loadedDefinitions; private QuestFactory() { @@ -58,46 +60,45 @@ namespace Cthangover.Core.Quests /// /// Seeds the internal dataByID dictionary from mod JSON if it - /// has not been seeded yet. When is still - /// mid‑initialisation, the call is silently deferred (logged at - /// debug level) so that early code paths don't crash; the next - /// access will retry. Each is - /// converted into a fresh with default - /// status and an empty tag set. + /// has not been seeded from the current data snapshot. When + /// is still mid‑initialisation, the call is + /// deferred so that early code paths don't crash; the next access + /// retries. Entities.Collect is cached by the entity resolver + /// until the next mod reload, so a new dictionary means new data: the + /// quests are rebuilt from it, a quest that stays keeps its instance and + /// state, a new one starts with default status and an empty tag set. /// private void EnsureLoaded() { - if (isLoaded) - return; - if (!ModManager.Instance.IsInitialized) { GameLogger.Log("QUEST", "mods not initialized, quest loading deferred"); return; } - isLoaded = true; - + Dictionary definitions; try { - var definitions = Entities.Collect(); - if (definitions != null) - { - foreach (var (id, def) in definitions) - { - var quest = new QuestBase - { - ID = def.ID, - Name = def.Name, - StatusToDescription = def.StatusToDescription ?? new Dictionary(), - }; - dataByID.Add(quest.ID, quest); - } - } + definitions = Entities.Collect(); } catch (Exception ex) { GameLogger.Log("QUEST", "failed to load quests from mods: " + ex.Message, LogLevel.Error); + return; + } + + if (ReferenceEquals(definitions, loadedDefinitions)) + return; + loadedDefinitions = definitions; + + var previous = new Dictionary(dataByID); + dataByID.Clear(); + foreach (var def in definitions.Values) + { + var quest = previous.TryGetValue(def.ID, out var kept) ? kept : new QuestBase { ID = def.ID }; + quest.Name = def.Name; + quest.StatusToDescription = def.StatusToDescription ?? new Dictionary(); + dataByID[quest.ID] = quest; } GameLogger.Log("QUEST", "loaded '" + dataByID.Count + "' quests"); @@ -127,6 +128,13 @@ namespace Cthangover.Core.Quests /// serialise the full quest state for the save file, and by the /// journal UI to populate the quest list window. /// + /// + /// Quests of the loaded save that no loaded mod defines (their mod left + /// the game). They take no part in the game, but the save writes them + /// back, so their progress returns with the mod. Cleared by a new game. + /// + public IReadOnlyList Unresolved => unresolved; + public List GetAll() { EnsureLoaded(); @@ -140,8 +148,8 @@ namespace Cthangover.Core.Quests /// corresponding entry by ID, and overwrites its /// and /// properties. Entries whose ID is not registered (a removed mod or a - /// quest renamed between sessions) are skipped with a warning so that - /// an ageing save cannot block loading. Reads from empty or null + /// quest renamed between sessions) go to with + /// a warning, so an ageing save neither blocks loading nor loses them. Reads from empty or null /// lists are silently ignored so that save files created before the /// quest system existed (or with no quest progress) do not break the /// pipeline. Publishes a single @@ -152,13 +160,15 @@ namespace Cthangover.Core.Quests { EnsureLoaded(); + unresolved.Clear(); if(Lists.IsEmpty(quests)) return; foreach (var questFromSave in quests) { if(!dataByID.TryGetValue(questFromSave.ID, out var quest)) { - GameLogger.Log("QUEST", "quest '" + questFromSave.ID + "' from save is not registered, skipped", LogLevel.Warning); + GameLogger.Log("QUEST", "quest '" + questFromSave.ID + "' from save is not registered; its state is kept in the save", LogLevel.Warning); + unresolved.Add(questFromSave); continue; } quest.Data = questFromSave.Data; @@ -183,6 +193,7 @@ namespace Cthangover.Core.Quests { EnsureLoaded(); + unresolved.Clear(); foreach (var quest in dataByID.Values) { quest.Status = QuestStatus.NotStarted; diff --git a/src/QuestsSaveable.cs b/src/QuestsSaveable.cs index 4b770fc..43d3621 100644 --- a/src/QuestsSaveable.cs +++ b/src/QuestsSaveable.cs @@ -27,7 +27,9 @@ namespace Cthangover.Core.Quests /// Writes the full quest state list into the mod slot. public void OnSave(ModSlot slot) { - slot.Data[BlobKey] = new DataBlob(1, QuestFactory.Instance.GetAll()); + var quests = QuestFactory.Instance.GetAll(); + quests.AddRange(QuestFactory.Instance.Unresolved); + slot.Data[BlobKey] = new DataBlob(1, quests); } /// Restores quest state from the mod slot; an absent blob keeps fresh quest state. diff --git a/tests/QuestsSaveableTests.cs b/tests/QuestsSaveableTests.cs new file mode 100644 index 0000000..4dda647 --- /dev/null +++ b/tests/QuestsSaveableTests.cs @@ -0,0 +1,48 @@ +namespace Cthangover.Mods.Quests.Tests +{ + using System.Collections.Generic; + using System.Linq; + using System.Text.Json; + using Cthangover.Core.Mods.Testing; + using Cthangover.Core.Quests; + using Cthangover.Core.Settings; + + /// + /// The quests slot holds the state of every content mod's quests, so a + /// quest whose mod left the game is still in a loaded save: it must not + /// take part in the game, and the next save must write it back so its + /// progress returns with the mod. + /// + public class QuestsSaveableTests + { + private const string UnknownId = "__test_no_such_quest"; + + [ModTest("save/unknown_quest_survives_load_and_save")] + static void UnknownQuestSurvivesLoadAndSave(ModTestContext ctx) + { + var factory = QuestFactory.Instance; + var before = JsonSerializer.Serialize(factory.GetAll()); + try + { + var unknown = new QuestBase { ID = UnknownId, Status = QuestStatus.Progress }; + unknown.Data.Tags.Add("met"); + var slot = new ModSlot(); + slot.Data["Quests"] = new DataBlob(1, new List { unknown }); + var saveable = new QuestsSaveable(); + + ModAssert.True(saveable.OnLoad(slot, out _), "slot with an unknown quest loads"); + ModAssert.False(factory.GetAll().Any(q => q.ID == UnknownId), "unknown quest takes no part in the game"); + + var saved = new ModSlot(); + saveable.OnSave(saved); + var kept = saved.Data["Quests"].ReadJson>().SingleOrDefault(q => q.ID == UnknownId); + ModAssert.NotNull(kept, "unknown quest written back"); + ModAssert.True(kept.Status == QuestStatus.Progress && kept.Data.Tags.Contains("met"), "its status and tags kept"); + } + finally + { + factory.SetAll(JsonSerializer.Deserialize>(before)); + } + } + } +}