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 293d5ca..4f6dc43 100644
--- a/manifest.json
+++ b/manifest.json
@@ -8,6 +8,9 @@
"sources": [
"src/**/*.cs"
],
+ "test_sources": [
+ "tests/*.cs"
+ ],
"depends": ["shell2d_base>=1", "ui_base>=1"],
"test_suites": [
{"id": "inventory", "activate": ["--itest=inventory"]}
diff --git a/src/Inventory.cs b/src/Inventory.cs
index 9fbc273..01ae6e9 100644
--- a/src/Inventory.cs
+++ b/src/Inventory.cs
@@ -30,6 +30,14 @@ namespace Cthangover.Core.Items
{
/// Ordered slot list; order drives UI layout and empty slots are removed on zero stacks.
public List Items { get; set; } = new();
+
+ ///
+ /// Stacks a loaded save names but no loaded mod defines (the mod was
+ /// removed or the id renamed). They are no usable stacks — every
+ /// container in has an item — but the items slot
+ /// writes them back, so they return with their mod.
+ ///
+ public List UnresolvedItems { get; } = new();
/// Fires on every successful add/remove with (itemId, signed delta); also mirrored as on the ModEventBus.
public event Action Change;
@@ -74,7 +82,7 @@ namespace Cthangover.Core.Items
{
if (string.IsNullOrWhiteSpace(id))
return false;
- return Items.Any(o => o.Item.ID == id);
+ return Items.Any(o => o.Item != null && o.Item.ID == id);
}
/// Returns the slot holding the given item, or null (null-safe).
@@ -124,6 +132,12 @@ namespace Cthangover.Core.Items
///
public void Add(string id, int count = 1)
{
+ if (count <= 0)
+ {
+ GameLogger.Log("INVENTORY", $"Add('{id}', {count}): count must be positive, ignored", LogLevel.Warning);
+ return;
+ }
+
var container = TryGet(id);
if (container != null)
{
@@ -161,6 +175,10 @@ namespace Cthangover.Core.Items
///
public bool Remove(string id, int count = 1)
{
+ // A negative count would pass the stock check and grow the stack.
+ if (count <= 0)
+ return false;
+
var container = TryGet(id);
if (container == null)
return false;
diff --git a/src/ItemActionFactory.cs b/src/ItemActionFactory.cs
index f3e50d2..090772b 100644
--- a/src/ItemActionFactory.cs
+++ b/src/ItemActionFactory.cs
@@ -1,63 +1,63 @@
-using System.Collections.Generic;
-using Cthangover.Core.Items;
-
-namespace Cthangover.Core.Factories.Impls
-{
- ///
- /// Registry of item action behaviours, indexed by ID. It discovers
- /// nothing itself: C# actions of every mod arrive through
- /// ItemActionConsumer as the kernel registers their assembly and
- /// leave with their mod, and GDScript-declared actions arrive through
- /// ItemActionBridge. A mod therefore introduces a new item action
- /// simply by containing a public class that implements the interface.
- ///
- /// The "null" ID string is treated as a no-op action (the
- /// fallback for items that don't have an on-use effect), avoiding
- /// the need for a sentinel NullItemAction class.
- ///
- /// The single instance lives in this mod assembly as a static lazy
- /// singleton; the static facade stays as the public API.
- ///
- public class ItemActionFactory
- {
- private static ItemActionFactory _instance;
-
- /// Static lazy singleton; kept as the public API.
- public static ItemActionFactory Instance => _instance ??= new ItemActionFactory();
-
- private readonly IDictionary data = new Dictionary();
-
- internal ItemActionFactory()
- {
- }
-
- /// Registers a single action under its ; the first action of an id wins, null/empty ids are ignored.
- public void Register(IItemAction action)
- {
- if (action != null && !string.IsNullOrEmpty(action.ID) && !data.ContainsKey(action.ID))
- data[action.ID] = action;
- }
-
- ///
- /// Removes exactly (its mod is being unloaded);
- /// an id owned by another action instance is left alone.
- ///
- public void Unregister(IItemAction action)
- {
- if (action != null && !string.IsNullOrEmpty(action.ID) && data.TryGetValue(action.ID, out var registered) && registered == action)
- data.Remove(action.ID);
- }
-
- ///
- /// Resolves the behaviour registered under .
- /// The special id "null" (or an empty value) is an explicit
- /// no-action marker and returns null, as does an unknown id.
- ///
- public IItemAction Get(string id)
- {
- if (string.IsNullOrWhiteSpace(id) || id == "null")
- return null;
- return data[id];
- }
- }
-}
+using System.Collections.Generic;
+using Cthangover.Core.Items;
+
+namespace Cthangover.Core.Factories.Impls
+{
+ ///
+ /// Registry of item action behaviours, indexed by ID. It discovers
+ /// nothing itself: C# actions of every mod arrive through
+ /// ItemActionConsumer as the kernel registers their assembly and
+ /// leave with their mod, and GDScript-declared actions arrive through
+ /// ItemActionBridge. A mod therefore introduces a new item action
+ /// simply by containing a public class that implements the interface.
+ ///
+ /// The "null" ID string is treated as a no-op action (the
+ /// fallback for items that don't have an on-use effect), avoiding
+ /// the need for a sentinel NullItemAction class.
+ ///
+ /// The single instance lives in this mod assembly as a static lazy
+ /// singleton; the static facade stays as the public API.
+ ///
+ public class ItemActionFactory
+ {
+ private static ItemActionFactory _instance;
+
+ /// Static lazy singleton; kept as the public API.
+ public static ItemActionFactory Instance => _instance ??= new ItemActionFactory();
+
+ private readonly IDictionary data = new Dictionary();
+
+ internal ItemActionFactory()
+ {
+ }
+
+ /// Registers a single action under its ; the first action of an id wins, null/empty ids are ignored.
+ public void Register(IItemAction action)
+ {
+ if (action != null && !string.IsNullOrEmpty(action.ID) && !data.ContainsKey(action.ID))
+ data[action.ID] = action;
+ }
+
+ ///
+ /// Removes exactly (its mod is being unloaded);
+ /// an id owned by another action instance is left alone.
+ ///
+ public void Unregister(IItemAction action)
+ {
+ if (action != null && !string.IsNullOrEmpty(action.ID) && data.TryGetValue(action.ID, out var registered) && registered == action)
+ data.Remove(action.ID);
+ }
+
+ ///
+ /// Resolves the behaviour registered under .
+ /// The special id "null" (or an empty value) is an explicit
+ /// no-action marker and returns null, as does an unknown id.
+ ///
+ public IItemAction Get(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id) || id == "null")
+ return null;
+ return data[id];
+ }
+ }
+}
diff --git a/src/ItemFactory.cs b/src/ItemFactory.cs
index 4fe0c60..f548f98 100644
--- a/src/ItemFactory.cs
+++ b/src/ItemFactory.cs
@@ -1,6 +1,4 @@
-using System.Collections.Generic;
using Cthangover.Core.Items;
-using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils;
@@ -17,8 +15,6 @@ namespace Cthangover.Core.Factories.Impls
/// Static lazy singleton; kept as the public API.
public static ItemFactory Instance => _instance ??= new ItemFactory();
- private Dictionary items;
-
///
/// Builds a fresh from the collected ,
/// resolving the sprite through , the on-use
@@ -31,14 +27,9 @@ namespace Cthangover.Core.Factories.Impls
if (string.IsNullOrEmpty(id))
return null;
- var infos = items ?? Entities.Collect();
-
- // The registry refuses mod-data access until Boot.IsReady, so
- // this snapshot can never be collected from a half-built mod set;
- // the cached field is reset when ModManager.Reload rebuilds the
- // registry.
- if (ModManager.Instance.IsInitialized)
- items ??= infos;
+ // Entities.Collect is cached by the entity resolver until the next
+ // mod reload, so reading it every time serves the current data.
+ var infos = Entities.Collect();
if (!infos.TryGetValue(id, out var info))
{
diff --git a/src/ItemsSaveable.cs b/src/ItemsSaveable.cs
index ed60b7e..c562290 100644
--- a/src/ItemsSaveable.cs
+++ b/src/ItemsSaveable.cs
@@ -4,6 +4,7 @@ using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Items;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
+using Cthangover.Core.Utils;
namespace Cthangover.Mods.Items
{
@@ -28,8 +29,13 @@ namespace Cthangover.Mods.Items
if (runtime == null)
return;
- slot.Data[InventoryBlob] = new DataBlob(1, runtime.Inventory.Items.Select(o =>
- new CItem { ID = o.Item.ID, Count = o.Count }).ToList());
+ var items = runtime.Inventory.Items
+ .Where(o => o.Item != null)
+ .Select(o => new CItem { ID = o.Item.ID, Count = o.Count })
+ .ToList();
+ if (runtime.Inventory is Inventory inventory)
+ items.AddRange(inventory.UnresolvedItems);
+ slot.Data[InventoryBlob] = new DataBlob(1, items);
slot.Data[RecipesBlob] = new DataBlob(1, runtime.RecipeData.Data.ToList());
}
@@ -43,11 +49,21 @@ namespace Cthangover.Mods.Items
if (slot.Data.TryGetValue(InventoryBlob, out var invBlob))
{
- var items = invBlob.ReadJson>();
- runtime.Inventory.Items = items?.Select(o =>
- (IItemContainer)new ItemContainer
- { Item = ItemFactory.Instance.Get(o.ID), Count = o.Count }).ToList()
- ?? new List();
+ var containers = new List();
+ var unresolved = (runtime.Inventory as Inventory)?.UnresolvedItems;
+ unresolved?.Clear();
+ foreach (var saved in invBlob.ReadJson>() ?? new List())
+ {
+ var item = ItemFactory.Instance.Get(saved.ID);
+ if (item != null)
+ {
+ containers.Add(new ItemContainer { Item = item, Count = saved.Count });
+ continue;
+ }
+ GameLogger.Log("INVENTORY", $"saved item '{saved.ID}' x{saved.Count} is not defined by any loaded mod; kept in the save", LogLevel.Warning);
+ unresolved?.Add(saved);
+ }
+ runtime.Inventory.Items = containers;
}
if (slot.Data.TryGetValue(RecipesBlob, out var recipeBlob))
diff --git a/src/RecipeData.cs b/src/RecipeData.cs
index d2dfdc2..d3dbf61 100644
--- a/src/RecipeData.cs
+++ b/src/RecipeData.cs
@@ -1,50 +1,50 @@
-using System.Collections.Generic;
-using System.Linq;
-using Cthangover.Core.Factories.Impls;
-using Cthangover.Core.Items;
-
-namespace Cthangover.Core.Settings
-{
- ///
- /// Tracks which crafting recipes the player has unlocked. Internally
- /// a of recipe IDs that provides O(1)
- /// lookup. Recipes can be queried by
- /// via , which resolves each ID through
- /// and
- /// filters the resulting objects.
- ///
- public class RecipeData : IRecipeData
- {
- /// Set of known recipe IDs (duplicate insertion is silently ignored).
- public HashSet Data { get; set; } = new();
-
- ///
- /// Unlocks a recipe by ID. Returns false if the recipe
- /// was already known (duplicate).
- ///
- public bool Add(string id)
- {
- return Data.Add(id);
- }
-
- /// true if the recipe with the given ID is already unlocked.
- public bool Has(string id)
- {
- return Data.Contains(id);
- }
-
- ///
- /// Returns all known recipes that belong to the specified
- /// . Each ID is resolved via
- ///
- /// and filtered; IDs that resolve to null are silently
- /// dropped.
- ///
- public List GetRecipesByType(WorkbenchType type)
- {
- return Data.Select(o => RecipeFactory.Instance.Get(o))
- .Where(o => o.WorkbenchType == type)
- .ToList();
- }
- }
-}
+using System.Collections.Generic;
+using System.Linq;
+using Cthangover.Core.Factories.Impls;
+using Cthangover.Core.Items;
+
+namespace Cthangover.Core.Settings
+{
+ ///
+ /// Tracks which crafting recipes the player has unlocked. Internally
+ /// a of recipe IDs that provides O(1)
+ /// lookup. Recipes can be queried by
+ /// via , which resolves each ID through
+ /// and
+ /// filters the resulting objects.
+ ///
+ public class RecipeData : IRecipeData
+ {
+ /// Set of known recipe IDs (duplicate insertion is silently ignored).
+ public HashSet Data { get; set; } = new();
+
+ ///
+ /// Unlocks a recipe by ID. Returns false if the recipe
+ /// was already known (duplicate).
+ ///
+ public bool Add(string id)
+ {
+ return Data.Add(id);
+ }
+
+ /// true if the recipe with the given ID is already unlocked.
+ public bool Has(string id)
+ {
+ return Data.Contains(id);
+ }
+
+ ///
+ /// Returns all known recipes that belong to the specified
+ /// . Each ID is resolved via
+ ///
+ /// and filtered; IDs that resolve to null are silently
+ /// dropped.
+ ///
+ public List GetRecipesByType(WorkbenchType type)
+ {
+ return Data.Select(o => RecipeFactory.Instance.Get(o))
+ .Where(o => o.WorkbenchType == type)
+ .ToList();
+ }
+ }
+}
diff --git a/src/RecipeFactory.cs b/src/RecipeFactory.cs
index 1c20145..2277270 100644
--- a/src/RecipeFactory.cs
+++ b/src/RecipeFactory.cs
@@ -52,12 +52,13 @@ namespace Cthangover.Core.Factories.Impls
///
private Dictionary EnsureInfos()
{
- if (_allInfos != null)
- return _allInfos;
-
+ // The resolver's dictionary identity is the data snapshot: a new one drops the built recipes.
var infos = Entities.Collect();
- if (ModManager.Instance.IsInitialized)
+ if (!ReferenceEquals(infos, _allInfos))
+ {
_allInfos = infos;
+ _cache.Clear();
+ }
return infos;
}
diff --git a/src/ui/InventoryBagBehaviour.cs b/src/ui/InventoryBagBehaviour.cs
index c3fd2d0..9462443 100644
--- a/src/ui/InventoryBagBehaviour.cs
+++ b/src/ui/InventoryBagBehaviour.cs
@@ -1,41 +1,41 @@
-using System.Collections.Generic;
-using Cthangover.Core.Items;
-using Cthangover.Mods.Interface.Base.Lists.Impls;
-
-namespace Cthangover.Core.UI.Inventory
-{
- ///
- /// Grid-based inventory display using ColumnCellListWidget. Binds an
- /// external List<IItemContainer>. The List property setter auto-refreshes
- /// the UI — intended for data-driven updates where the inventory source
- /// pushes a new list reference rather than items mutating in place.
- ///
- public partial class InventoryBagBehaviour : ColumnCellListWidget
- {
- private List list;
-
- ///
- /// Bound inventory data source. Setting this property immediately rebuilds
- /// the grid display via . The entire grid is
- /// reconstructed from the new list reference.
- ///
- public List List
- {
- get => list;
- set
- {
- list = value;
- Refresh();
- }
- }
-
- ///
- /// Provides the current as the data collection for the
- /// base.
- ///
- public override ICollection CreateModels()
- {
- return list;
- }
- }
-}
+using System.Collections.Generic;
+using Cthangover.Core.Items;
+using Cthangover.Mods.Interface.Base.Lists.Impls;
+
+namespace Cthangover.Core.UI.Inventory
+{
+ ///
+ /// Grid-based inventory display using ColumnCellListWidget. Binds an
+ /// external List<IItemContainer>. The List property setter auto-refreshes
+ /// the UI — intended for data-driven updates where the inventory source
+ /// pushes a new list reference rather than items mutating in place.
+ ///
+ public partial class InventoryBagBehaviour : ColumnCellListWidget
+ {
+ private List list;
+
+ ///
+ /// Bound inventory data source. Setting this property immediately rebuilds
+ /// the grid display via . The entire grid is
+ /// reconstructed from the new list reference.
+ ///
+ public List List
+ {
+ get => list;
+ set
+ {
+ list = value;
+ Refresh();
+ }
+ }
+
+ ///
+ /// Provides the current as the data collection for the
+ /// base.
+ ///
+ public override ICollection CreateModels()
+ {
+ return list;
+ }
+ }
+}
diff --git a/tests/InventoryTests.cs b/tests/InventoryTests.cs
new file mode 100644
index 0000000..ffa1edb
--- /dev/null
+++ b/tests/InventoryTests.cs
@@ -0,0 +1,36 @@
+namespace Cthangover.Mods.Items.Tests
+{
+ using System.Linq;
+ using Cthangover.Core.Items;
+ using Cthangover.Core.Mods.Resolvers;
+ using Cthangover.Core.Mods.Testing;
+
+ ///
+ /// Stack arithmetic of : a count that is not
+ /// positive is no operation at all — a negative removal must not grow a
+ /// stack and an addition must not create an empty or negative one.
+ ///
+ public class InventoryTests
+ {
+ [ModTest("inventory/non_positive_counts_change_nothing")]
+ static void NonPositiveCountsChangeNothing(ModTestContext ctx)
+ {
+ var id = Entities.Collect().Keys.First();
+ var inventory = new Inventory();
+ inventory.Add(id, 2);
+ var before = inventory.CheckCount(id);
+
+ ModAssert.False(inventory.Remove(id, -5), "negative removal is refused");
+ ModAssert.False(inventory.Remove(id, 0), "zero removal is refused");
+ inventory.Add(id, -3);
+ inventory.Add(id, 0);
+ ModAssert.Equal(before, inventory.CheckCount(id), "stack count after non-positive operations");
+
+ var other = Entities.Collect().Keys.FirstOrDefault(k => !inventory.HasItem(k));
+ if (other == null)
+ return;
+ inventory.Add(other, 0);
+ ModAssert.False(inventory.HasItem(other), "zero addition creates no stack");
+ }
+ }
+}
diff --git a/tests/ItemsSaveableTests.cs b/tests/ItemsSaveableTests.cs
new file mode 100644
index 0000000..c6d8c47
--- /dev/null
+++ b/tests/ItemsSaveableTests.cs
@@ -0,0 +1,54 @@
+namespace Cthangover.Mods.Items.Tests
+{
+ using System.Collections.Generic;
+ using System.Linq;
+ using Cthangover.Core.Items;
+ using Cthangover.Core.Mods.Resolvers;
+ using Cthangover.Core.Mods.Testing;
+ using Cthangover.Core.Settings;
+
+ ///
+ /// The items slot across a change of the game's content: a save may name
+ /// an item no loaded mod defines any more (its mod was removed or the id
+ /// renamed). Loading it must keep the rest of the inventory usable, and
+ /// the next save must still be written — with the unknown stack kept, so
+ /// it comes back with its mod.
+ ///
+ public class ItemsSaveableTests
+ {
+ private const string UnknownId = "__test/no_such_item";
+
+ [ModTest("save/unknown_item_survives_load_and_save")]
+ static void UnknownItemSurvivesLoadAndSave(ModTestContext ctx)
+ {
+ var known = Entities.Collect().Keys.First();
+ var runtime = GameData.Instance.Runtime;
+ var original = runtime.Inventory;
+ try
+ {
+ runtime.Inventory = new Inventory();
+ var slot = new ModSlot();
+ slot.Data["Inventory"] = new DataBlob(1, new List
+ {
+ new() { ID = known, Count = 4 },
+ new() { ID = UnknownId, Count = 3 },
+ });
+ var saveable = new ItemsSaveable();
+
+ ModAssert.True(saveable.OnLoad(slot, out _), "slot with an unknown item loads");
+ ModAssert.Equal(4, runtime.Inventory.CheckCount(known), "known stack restored");
+ ModAssert.False(runtime.Inventory.HasItem(UnknownId), "unknown item is not a usable stack");
+
+ var saved = new ModSlot();
+ saveable.OnSave(saved);
+ var items = saved.Data["Inventory"].ReadJson>();
+ ModAssert.Equal(4, items.Single(i => i.ID == known).Count, "known stack saved");
+ ModAssert.Equal(3, items.Single(i => i.ID == UnknownId).Count, "unknown stack kept in the save");
+ }
+ finally
+ {
+ runtime.Inventory = original;
+ }
+ }
+ }
+}