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