Inventory refuses non-positive counts; unknown saved items kept; factories follow reloads

This commit is contained in:
cthangover committed 2026-10-21 12:31:55 +02:00
1 parent d29df6dab6
commit 6bb47971a3
11 files changed
+334 -215

No files matched your search

+19 -1
View File
@@ -30,6 +30,14 @@ namespace Cthangover.Core.Items
{
/// <summary>Ordered slot list; order drives UI layout and empty slots are removed on zero stacks.</summary>
public List<IItemContainer> Items { get; set; } = new();
/// <summary>
/// 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 <see cref="Items"/> has an item — but the items slot
/// writes them back, so they return with their mod.
/// </summary>
public List<Settings.CItem> UnresolvedItems { get; } = new();
/// <summary>Fires on every successful add/remove with (itemId, signed delta); also mirrored as <see cref="Events.InventoryChangedEvent"/> on the ModEventBus.</summary>
public event Action<string, int> 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);
}
/// <summary>Returns the slot holding the given item, or <c>null</c> (null-safe).</summary>
@@ -124,6 +132,12 @@ namespace Cthangover.Core.Items
/// </summary>
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
/// </summary>
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;
+63 -63
View File
@@ -1,63 +1,63 @@
using System.Collections.Generic;
using Cthangover.Core.Items;
namespace Cthangover.Core.Factories.Impls
{
/// <summary>
/// Registry of item action behaviours, indexed by <c>ID</c>. It discovers
/// nothing itself: C# actions of every mod arrive through
/// <c>ItemActionConsumer</c> as the kernel registers their assembly and
/// leave with their mod, and GDScript-declared actions arrive through
/// <c>ItemActionBridge</c>. A mod therefore introduces a new item action
/// simply by containing a public class that implements the interface.
///
/// The <c>"null"</c> 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 <c>NullItemAction</c> class.
/// <para/>
/// The single instance lives in this mod assembly as a static lazy
/// singleton; the static facade stays as the public API.
/// </summary>
public class ItemActionFactory
{
private static ItemActionFactory _instance;
/// <summary>Static lazy singleton; kept as the public API.</summary>
public static ItemActionFactory Instance => _instance ??= new ItemActionFactory();
private readonly IDictionary<string, IItemAction> data = new Dictionary<string, IItemAction>();
internal ItemActionFactory()
{
}
/// <summary>Registers a single action under its <see cref="IItemAction.ID"/>; the first action of an id wins, null/empty ids are ignored.</summary>
public void Register(IItemAction action)
{
if (action != null && !string.IsNullOrEmpty(action.ID) && !data.ContainsKey(action.ID))
data[action.ID] = action;
}
/// <summary>
/// Removes exactly <paramref name="action"/> (its mod is being unloaded);
/// an id owned by another action instance is left alone.
/// </summary>
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);
}
/// <summary>
/// Resolves the behaviour registered under <paramref name="id"/>.
/// The special id <c>"null"</c> (or an empty value) is an explicit
/// no-action marker and returns <c>null</c>, as does an unknown id.
/// </summary>
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
{
/// <summary>
/// Registry of item action behaviours, indexed by <c>ID</c>. It discovers
/// nothing itself: C# actions of every mod arrive through
/// <c>ItemActionConsumer</c> as the kernel registers their assembly and
/// leave with their mod, and GDScript-declared actions arrive through
/// <c>ItemActionBridge</c>. A mod therefore introduces a new item action
/// simply by containing a public class that implements the interface.
///
/// The <c>"null"</c> 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 <c>NullItemAction</c> class.
/// <para/>
/// The single instance lives in this mod assembly as a static lazy
/// singleton; the static facade stays as the public API.
/// </summary>
public class ItemActionFactory
{
private static ItemActionFactory _instance;
/// <summary>Static lazy singleton; kept as the public API.</summary>
public static ItemActionFactory Instance => _instance ??= new ItemActionFactory();
private readonly IDictionary<string, IItemAction> data = new Dictionary<string, IItemAction>();
internal ItemActionFactory()
{
}
/// <summary>Registers a single action under its <see cref="IItemAction.ID"/>; the first action of an id wins, null/empty ids are ignored.</summary>
public void Register(IItemAction action)
{
if (action != null && !string.IsNullOrEmpty(action.ID) && !data.ContainsKey(action.ID))
data[action.ID] = action;
}
/// <summary>
/// Removes exactly <paramref name="action"/> (its mod is being unloaded);
/// an id owned by another action instance is left alone.
/// </summary>
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);
}
/// <summary>
/// Resolves the behaviour registered under <paramref name="id"/>.
/// The special id <c>"null"</c> (or an empty value) is an explicit
/// no-action marker and returns <c>null</c>, as does an unknown id.
/// </summary>
public IItemAction Get(string id)
{
if (string.IsNullOrWhiteSpace(id) || id == "null")
return null;
return data[id];
}
}
}
+3 -12
View File
@@ -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
/// <summary>Static lazy singleton; kept as the public API.</summary>
public static ItemFactory Instance => _instance ??= new ItemFactory();
private Dictionary<string, ItemInfo> items;
/// <summary>
/// Builds a fresh <see cref="Item"/> from the collected <see cref="ItemInfo"/>,
/// resolving the sprite through <see cref="ItemSpriteFactory"/>, the on-use
@@ -31,14 +27,9 @@ namespace Cthangover.Core.Factories.Impls
if (string.IsNullOrEmpty(id))
return null;
var infos = items ?? Entities.Collect<ItemInfo>();
// 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<ItemInfo>();
if (!infos.TryGetValue(id, out var info))
{
+23 -7
View File
@@ -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<List<CItem>>();
runtime.Inventory.Items = items?.Select(o =>
(IItemContainer)new ItemContainer
{ Item = ItemFactory.Instance.Get(o.ID), Count = o.Count }).ToList()
?? new List<IItemContainer>();
var containers = new List<IItemContainer>();
var unresolved = (runtime.Inventory as Inventory)?.UnresolvedItems;
unresolved?.Clear();
foreach (var saved in invBlob.ReadJson<List<CItem>>() ?? new List<CItem>())
{
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))
+50 -50
View File
@@ -1,50 +1,50 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Items;
namespace Cthangover.Core.Settings
{
/// <summary>
/// Tracks which crafting recipes the player has unlocked. Internally
/// a <see cref="HashSet{String}"/> of recipe IDs that provides <c>O(1)</c>
/// lookup. Recipes can be queried by <see cref="IRecipe.WorkbenchType"/>
/// via <see cref="GetRecipesByType"/>, which resolves each ID through
/// <see cref="Cthangover.Core.Factories.Impls.RecipeFactory"/> and
/// filters the resulting <see cref="IRecipe"/> objects.
/// </summary>
public class RecipeData : IRecipeData
{
/// <summary>Set of known recipe IDs (duplicate insertion is silently ignored).</summary>
public HashSet<string> Data { get; set; } = new();
/// <summary>
/// Unlocks a recipe by ID. Returns <c>false</c> if the recipe
/// was already known (duplicate).
/// </summary>
public bool Add(string id)
{
return Data.Add(id);
}
/// <summary><c>true</c> if the recipe with the given ID is already unlocked.</summary>
public bool Has(string id)
{
return Data.Contains(id);
}
/// <summary>
/// Returns all known recipes that belong to the specified
/// <see cref="WorkbenchType"/>. Each ID is resolved via
/// <see cref="Cthangover.Core.Factories.Impls.RecipeFactory.Instance"/>
/// and filtered; IDs that resolve to <c>null</c> are silently
/// dropped.
/// </summary>
public List<IRecipe> 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
{
/// <summary>
/// Tracks which crafting recipes the player has unlocked. Internally
/// a <see cref="HashSet{String}"/> of recipe IDs that provides <c>O(1)</c>
/// lookup. Recipes can be queried by <see cref="IRecipe.WorkbenchType"/>
/// via <see cref="GetRecipesByType"/>, which resolves each ID through
/// <see cref="Cthangover.Core.Factories.Impls.RecipeFactory"/> and
/// filters the resulting <see cref="IRecipe"/> objects.
/// </summary>
public class RecipeData : IRecipeData
{
/// <summary>Set of known recipe IDs (duplicate insertion is silently ignored).</summary>
public HashSet<string> Data { get; set; } = new();
/// <summary>
/// Unlocks a recipe by ID. Returns <c>false</c> if the recipe
/// was already known (duplicate).
/// </summary>
public bool Add(string id)
{
return Data.Add(id);
}
/// <summary><c>true</c> if the recipe with the given ID is already unlocked.</summary>
public bool Has(string id)
{
return Data.Contains(id);
}
/// <summary>
/// Returns all known recipes that belong to the specified
/// <see cref="WorkbenchType"/>. Each ID is resolved via
/// <see cref="Cthangover.Core.Factories.Impls.RecipeFactory.Instance"/>
/// and filtered; IDs that resolve to <c>null</c> are silently
/// dropped.
/// </summary>
public List<IRecipe> GetRecipesByType(WorkbenchType type)
{
return Data.Select(o => RecipeFactory.Instance.Get(o))
.Where(o => o.WorkbenchType == type)
.ToList();
}
}
}
+5 -4
View File
@@ -52,12 +52,13 @@ namespace Cthangover.Core.Factories.Impls
/// </summary>
private Dictionary<string, RecipeInfo> 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<RecipeInfo>();
if (ModManager.Instance.IsInitialized)
if (!ReferenceEquals(infos, _allInfos))
{
_allInfos = infos;
_cache.Clear();
}
return infos;
}
+41 -41
View File
@@ -1,41 +1,41 @@
using System.Collections.Generic;
using Cthangover.Core.Items;
using Cthangover.Mods.Interface.Base.Lists.Impls;
namespace Cthangover.Core.UI.Inventory
{
/// <summary>
/// Grid-based inventory display using ColumnCellListWidget. Binds an
/// external List&lt;IItemContainer&gt;. 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.
/// </summary>
public partial class InventoryBagBehaviour : ColumnCellListWidget<InventoryItemBehaviour, IItemContainer>
{
private List<IItemContainer> list;
/// <summary>
/// Bound inventory data source. Setting this property immediately rebuilds
/// the grid display via <see cref="Refresh"/>. The entire grid is
/// reconstructed from the new list reference.
/// </summary>
public List<IItemContainer> List
{
get => list;
set
{
list = value;
Refresh();
}
}
/// <summary>
/// Provides the current <see cref="List"/> as the data collection for the
/// <see cref="ColumnCellListWidget{TItem, TModel}"/> base.
/// </summary>
public override ICollection<IItemContainer> CreateModels()
{
return list;
}
}
}
using System.Collections.Generic;
using Cthangover.Core.Items;
using Cthangover.Mods.Interface.Base.Lists.Impls;
namespace Cthangover.Core.UI.Inventory
{
/// <summary>
/// Grid-based inventory display using ColumnCellListWidget. Binds an
/// external List&lt;IItemContainer&gt;. 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.
/// </summary>
public partial class InventoryBagBehaviour : ColumnCellListWidget<InventoryItemBehaviour, IItemContainer>
{
private List<IItemContainer> list;
/// <summary>
/// Bound inventory data source. Setting this property immediately rebuilds
/// the grid display via <see cref="Refresh"/>. The entire grid is
/// reconstructed from the new list reference.
/// </summary>
public List<IItemContainer> List
{
get => list;
set
{
list = value;
Refresh();
}
}
/// <summary>
/// Provides the current <see cref="List"/> as the data collection for the
/// <see cref="ColumnCellListWidget{TItem, TModel}"/> base.
/// </summary>
public override ICollection<IItemContainer> CreateModels()
{
return list;
}
}
}