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
7 files changed
+143 -24

No files matched your search

+3
View File
@@ -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"]}
+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;
+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))
+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;
}
+36
View File
@@ -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;
/// <summary>
/// Stack arithmetic of <see cref="Inventory"/>: 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.
/// </summary>
public class InventoryTests
{
[ModTest("inventory/non_positive_counts_change_nothing")]
static void NonPositiveCountsChangeNothing(ModTestContext ctx)
{
var id = Entities.Collect<ItemInfo>().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<ItemInfo>().Keys.FirstOrDefault(k => !inventory.HasItem(k));
if (other == null)
return;
inventory.Add(other, 0);
ModAssert.False(inventory.HasItem(other), "zero addition creates no stack");
}
}
}
+54
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ItemInfo>().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<CItem>
{
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<List<CItem>>();
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;
}
}
}
}