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));
+ }
+ }
+ }
+}