This commit is contained in:
iisaev committed 2026-10-18 12:41:40 +03:00
commit 370cc5bbbd
29 files changed
+3093

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
mod_items_base_name = Items Base
mod_items_base_desc = Item definitions and factories, inventory and recipe state, inventory UI and their save state.
+2
View File
@@ -0,0 +1,2 @@
mod_items_base_name = Предметы и инвентарь
mod_items_base_desc = Определения предметов и фабрики, состояние инвентаря и рецептов, интерфейс инвентаря и их сохранение.
+15
View File
@@ -0,0 +1,15 @@
{
"id": "items_base",
"name": "Items Base",
"version": "1.0.0",
"author": "ct",
"description": "Item definitions and factories, inventory and recipe state, inventory UI and their save state",
"category": "gameplay",
"sources": [
"src/**/*.cs"
],
"depends": ["base2d>=1", "interface>=1"],
"test_suites": [
{"id": "inventory", "activate": ["--itest=inventory"]}
]
}
Binary file not shown.
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
namespace Cthangover.Core.Items
{
/// <summary>
/// Plain POCO implementing <c>IIngredient</c>. Used by
/// <c>RecipeFactory</c> to materialise ingredient entries from
/// <c>IngredientInfo</c> DTOs after resolving the item reference
/// through <c>ItemFactory</c>.
/// </summary>
public class Ingredient : IIngredient{
/// <summary>The resolved item this formula entry references.</summary>
public IItem Item { get; set; }
/// <summary>How many copies the recipe consumes or produces.</summary>
public int Count { get; set; }
}
}
+25
View File
@@ -0,0 +1,25 @@
using System;
using System.Text.Json.Serialization;
using Cthangover.Core.Factories;
namespace Cthangover.Core.Items
{
/// <summary>
/// JSON deserialization DTO for a recipe ingredient entry. Carries
/// only the item <c>ID</c> string and <c>Count</c> — not the resolved
/// <c>IItem</c> — because recipe JSON files are loaded before item
/// definitions may be fully populated across mods. The actual item
/// reference is wired up later by <c>RecipeFactory.CreateIngredient</c>,
/// which looks up the ID through <c>ItemFactory</c> and validates
/// that the referenced item exists.
/// </summary>
[Serializable]
public class IngredientInfo : IIdentifiable
{
/// <summary>Item ID this entry references; serialized as "Id" in JSON.</summary>
[JsonPropertyName("Id")]
public string ID { get; set; }
/// <summary>Quantity consumed or produced by the recipe.</summary>
public int Count { get; set; }
}
}
+193
View File
@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Events;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Items
{
/// <summary>
/// Concrete player inventory backed by an ordered <c>List&lt;IItemContainer&gt;</c>.
/// Uses LINQ scans (<c>FirstOrDefault</c>, <c>Any</c>) rather than a
/// <c>Dictionary</c> because inventory sizes in this game are small
/// enough that O(n) lookups are imperceptible, and list ordering
/// directly drives UI slot layout — the first container appears in
/// the first grid cell.
///
/// On <c>Remove</c>, when a stack count reaches zero the entire
/// container is deleted from the list rather than kept as an empty
/// slot, preventing the UI from rendering gaps. The constructor seeds
/// wolf meat and branches for testing convenience — in production,
/// save data would override this initial state.
///
/// The <c>Change</c> event fires with <c>(itemId, delta)</c> so
/// subscribers receive full context. Additionally, each mutation
/// publishes an <c>InventoryChangedEvent</c> to the global
/// <c>ModEventBus</c>.
/// </summary>
public class Inventory : IInventory
{
/// <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>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;
/// <summary>
/// Creates an inventory pre-seeded with the starter kit (10 wolf meat,
/// 10 branches) so a new game always has basic supplies.
/// </summary>
public Inventory()
{
AddStarterItem("food/wolf_meat", 10);
AddStarterItem("resource/branches", 10);
}
/// <summary>
/// Seeds one starter stack, skipping the entry when the item definition
/// cannot be resolved. This can legitimately happen when the inventory is
/// constructed while the launcher has not finished loading mods; adding a
/// container with a <c>null</c> item would otherwise crash every later
/// <see cref="HasItem(string)"/> call.
/// </summary>
private void AddStarterItem(string id, int count)
{
var item = ItemFactory.Instance.Get(id);
if (item == null)
{
GameLogger.Log("INVENTORY", $"starter item '{id}' not found, skipping seed", LogLevel.Warning);
return;
}
Items.Add(new ItemContainer { Item = item, Count = count });
}
/// <summary>True when any slot holds the given item (null-safe).</summary>
public bool HasItem(IItem item)
{
if (item == null)
return false;
return HasItem(item.ID);
}
/// <summary>True when any slot holds the item with the given id; null/empty ids return false.</summary>
public bool HasItem(string id)
{
if (string.IsNullOrWhiteSpace(id))
return false;
return Items.Any(o => o.Item.ID == id);
}
/// <summary>Returns the slot holding the given item, or <c>null</c> (null-safe).</summary>
public IItemContainer TryGet(IItem item)
{
if (item == null)
return null;
return TryGet(item.ID);
}
/// <summary>Returns the slot holding the given id, or <c>null</c> for null/empty ids.</summary>
public IItemContainer TryGet(string id)
{
if (string.IsNullOrWhiteSpace(id))
return null;
return Items.FirstOrDefault(o => o.Item != null && o.Item.ID == id);
}
/// <summary>Total count across slots for the given item (0 when absent or null).</summary>
public int CheckCount(IItem item)
{
if (item == null)
return 0;
return CheckCount(item.ID);
}
/// <summary>Total count across slots for the given id (0 when absent).</summary>
public int CheckCount(string id)
{
var container = TryGet(id);
return container?.Count ?? 0;
}
/// <summary>Adds the item (delegates to the id overload); null items are ignored.</summary>
public void Add(IItem item, int count = 1)
{
if (item == null)
return;
Add(item.ID, count);
}
/// <summary>
/// Stacks onto an existing slot or creates a new one by resolving the id
/// through <see cref="Factories.Impls.ItemFactory"/>; unknown ids are
/// silently ignored. Fires <see cref="Change"/> and publishes
/// <see cref="Events.InventoryChangedEvent"/> on success.
/// </summary>
public void Add(string id, int count = 1)
{
var container = TryGet(id);
if (container != null)
{
container.Count += count;
Change?.Invoke(id, count);
PublishInventoryChanged(id, count, container.Count);
return;
}
var item = ItemFactory.Instance.Get(id);
if (item != null)
{
container = new ItemContainer
{
Item = item,
Count = count,
};
Items.Add(container);
Change?.Invoke(id, count);
PublishInventoryChanged(id, count, count);
}
}
/// <summary>Removes the item (delegates to the id overload); null items return false.</summary>
public bool Remove(IItem item, int count = 1)
{
if (item == null)
return false;
return Remove(item.ID, count);
}
/// <summary>
/// Removes <paramref name="count"/> items from the slot, deleting the
/// container when the stack reaches zero. Returns <c>false</c> without
/// changes when the slot holds fewer items than requested.
/// </summary>
public bool Remove(string id, int count = 1)
{
var container = TryGet(id);
if (container == null)
return false;
var delta = container.Count - count;
if (delta < 0)
return false;
if (delta == 0)
Items.Remove(container);
else
container.Count -= count;
var newCount = delta == 0 ? 0 : delta;
Change?.Invoke(id, -count);
PublishInventoryChanged(id, -count, newCount);
return true;
}
private static void PublishInventoryChanged(string itemId, int delta, int newCount)
{
ModEventBus.Publish(new InventoryChangedEvent
{
ItemId = itemId,
Delta = delta,
NewCount = newCount,
});
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Items
{
/// <summary>
/// Concrete item instance produced by <see cref="Factories.Impls.ItemFactory"/>
/// from an <see cref="ItemInfo"/> DTO. The factory builds a fresh instance per
/// <c>Get</c> call (resolving the sprite and the on-use action by id), so
/// callers may mutate their copy freely — inventories share the definition
/// fields but each slot only tracks its own count.
/// </summary>
public class Item : IItem
{
/// <summary>Unique item ID (path-style, e.g. <c>"food/ration"</c>); the factory lookup key.</summary>
public string ID { get; set; }
/// <summary>Display name (resolved locale string).</summary>
public string Name { get; set; }
/// <summary>Tooltip/description text (resolved locale string).</summary>
public string Description { get; set; }
/// <summary>Base purchase price in the game economy.</summary>
public int Cost { get; set; }
/// <summary>Icon texture resolved from the mod's <c>items</c> group via <see cref="Factories.Impls.ItemSpriteFactory"/>.</summary>
public Texture2D Sprite { get; set; }
/// <summary><see cref="ItemType"/> flags describing the item's categories.</summary>
public ItemType ItemType { get; set; }
/// <summary>On-use behaviour resolved via <see cref="ItemActionFactory"/>; <c>null</c> means no use effect.</summary>
public IItemAction ItemAction { get; set; }
/// <summary>String-keyed extension bag imported from extra JSON keys, for mod-defined item data.</summary>
public PropertyData Properties { get; set; } = new();
}
}
+26
View File
@@ -0,0 +1,26 @@
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Items;
using Cthangover.Core.Mods.Consumers;
namespace Cthangover.Mods.Items
{
/// <summary>
/// Feeds <see cref="ItemActionFactory"/> with every mod's C#
/// <see cref="IItemAction"/> implementations as the mods load (for example
/// cooking's recipe actions) and removes them when their mod unloads, so a
/// hot-reloaded mod's actions run its fresh code and the dying assembly is
/// not pinned. GDScript-declared actions do not come from an assembly and
/// still arrive through <see cref="ItemActionBridge"/>.
/// </summary>
public class ItemActionConsumer : ModTypeConsumer<IItemAction>
{
/// <inheritdoc />
public override string Id => "items.actions";
/// <inheritdoc />
protected override void Add(IItemAction instance, string modId) => ItemActionFactory.Instance.Register(instance);
/// <inheritdoc />
protected override void Remove(IItemAction instance, string modId) => ItemActionFactory.Instance.Unregister(instance);
}
}
+63
View File
@@ -0,0 +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];
}
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Cthangover.Core.Items
{
/// <summary>
/// Concrete inventory slot holding a shared <c>IItem</c> reference
/// and a mutable stack count. The item reference is shared across
/// every container that holds the same item type — only the count
/// is slot-specific — which keeps memory footprint low when
/// multiple stacks of the same item exist (e.g. 3 stacks of arrows
/// in different inventory rows).
/// </summary>
public class ItemContainer : IItemContainer
{
/// <summary>The shared item definition held by this slot (one instance per item type, not per stack).</summary>
public IItem Item { get; set; }
/// <summary>Stack size; <see cref="Inventory"/> deletes the container when it reaches zero.</summary>
public int Count { get; set; }
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Collections.Generic;
using Cthangover.Core.Items;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Factories.Impls
{
/// <summary>
/// The single instance lives in this mod assembly as a static lazy
/// singleton; the static facade stays as the public API.
/// </summary>
public class ItemFactory
{
private static ItemFactory _instance;
/// <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
/// action through <see cref="ItemActionFactory"/>, and importing extra JSON
/// keys into <see cref="Item.Properties"/>. Unknown or empty ids return
/// <c>null</c>.
/// </summary>
public IItem Get(string id)
{
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;
if (!infos.TryGetValue(id, out var info))
{
GameLogger.Log("FACTORY", $"item '{id}' not found", LogLevel.Error);
return null;
}
var properties = new PropertyData();
PropertyData.ImportJsonProperties(properties, info.ExtraProperties);
return new Item
{
ID = info.ID,
Name = info.Name,
Description = info.Description,
Cost = info.Cost,
Sprite = ItemSpriteFactory.Instance.Get(info.Sprite),
ItemType = info.ItemType,
ItemAction = ItemActionFactory.Instance.Get(info.ItemAction),
Properties = properties,
};
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Cthangover.Core.Factories;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Items
{
/// <summary>
/// JSON-serializable item descriptor collected by the entity resolver
/// (<c>[EntityKind("item")]</c> — files declare <c>"Kind": "item"</c>).
/// Typed fields cover the stock item schema; any additional JSON keys are
/// captured by <see cref="ExtraProperties"/> and imported by
/// <see cref="Factories.Impls.ItemFactory"/> into <see cref="Item.Properties"/>,
/// so mods can attach custom data to items without schema changes.
/// </summary>
[Serializable]
[EntityKind("item")]
public class ItemInfo : IIdentifiable
{
/// <summary>Unique item ID, serialized as "Id" in JSON; path-style ids (e.g. "food/ration") double as save-data references.</summary>
[JsonPropertyName("Id")]
public string ID { get; set; }
/// <summary>Locale key for the item's display name, resolved from .properties files.</summary>
public string Name { get; set; }
/// <summary>Locale key for the item's tooltip/description text.</summary>
public string Description { get; set; }
/// <summary>Base purchase price in the game economy.</summary>
public int Cost { get; set; }
/// <summary>Sprite path inside the mod's <c>items</c> texture group, resolved by <see cref="Factories.Impls.ItemSpriteFactory"/>.</summary>
public string Sprite { get; set; }
/// <summary>Category flags parsed from a comma-separated string (e.g. "Resource,Food") via the flags enum converter.</summary>
[JsonConverter(typeof(FlagsStringEnumConverter<ItemType>))]
public ItemType ItemType { get; set; }
/// <summary>ID of the on-use behaviour, resolved through <see cref="Factories.Impls.ItemActionFactory"/>; "null"/empty means no use effect.</summary>
public string ItemAction { get; set; }
/// <summary>Captures every JSON key not matched by the typed fields; imported into <see cref="Item.Properties"/> at factory time.</summary>
[JsonExtensionData]
public Dictionary<string, object> ExtraProperties { get; set; }
}
}
+40
View File
@@ -0,0 +1,40 @@
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Items;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
namespace Cthangover.Mods.Items
{
/// <summary>
/// Lifecycle entry point of the items mod. The core no longer owns the
/// inventory/recipe state or the item factories, so this initializer:
/// <list type="number">
/// <item>installs the <see cref="RuntimeData"/> factory hooks, so the
/// core state bag lazily produces the mod-owned <c>Inventory</c> and
/// <c>RecipeData</c> on first access; and</item>
/// <item>binds <see cref="ItemActionBridge"/> to this mod's
/// <c>ItemActionFactory</c>, flushing every GDScript-declared action
/// buffered so far.</item>
/// </list>
/// C# item actions of every mod reach the factory through
/// <see cref="ItemActionConsumer"/>, not through this initializer.
/// Persistence lives in <see cref="ItemsSaveable"/>, discovered
/// automatically by <c>ModSaveableRegistry</c>.
/// </summary>
public class ItemsModInitializer : IModInitializer
{
/// <summary>No per-mod work is needed on load; discovery is reflection-based.</summary>
public void OnModLoaded(string modId)
{
}
/// <summary>Installs the RuntimeData factory hooks and binds the item-action bridge (both idempotent).</summary>
public void OnModResourcesReady()
{
RuntimeData.InventoryFactory ??= () => new Inventory();
RuntimeData.RecipeDataFactory ??= () => new RecipeData();
ItemActionBridge.Bind(action => ItemActionFactory.Instance.Register(action));
}
}
}
+60
View File
@@ -0,0 +1,60 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Items;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
namespace Cthangover.Mods.Items
{
/// <summary>
/// Persists the player inventory and unlocked recipes in the items mod's
/// own save slot. The core used to carry the <c>"Inventory"</c> and
/// <c>"Recipes"</c> blobs inside the <c>__core__</c> slot; the blob
/// format (CItem id/count pairs, recipe id list) is unchanged — only the
/// owning slot moved. Backward compatibility with old saves' core blobs
/// is intentionally not provided (precedent: recruiting) — old saves
/// start with an empty inventory and no unlocked recipes.
/// </summary>
public class ItemsSaveable : IModSaveable
{
private const string InventoryBlob = "Inventory";
private const string RecipesBlob = "Recipes";
/// <summary>Writes item stacks and unlocked recipe IDs into the mod slot.</summary>
public void OnSave(ModSlot slot)
{
var runtime = GameData.Instance?.Runtime;
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());
slot.Data[RecipesBlob] = new DataBlob(1, runtime.RecipeData.Data.ToList());
}
/// <summary>Restores inventory and recipes; absent blobs leave the current state untouched.</summary>
public bool OnLoad(ModSlot slot, out string errorMessage)
{
errorMessage = null;
var runtime = GameData.Instance?.Runtime;
if (runtime == null || slot?.Data == null)
return true;
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>();
}
if (slot.Data.TryGetValue(RecipesBlob, out var recipeBlob))
runtime.RecipeData.Data = recipeBlob.ReadJson<List<string>>()?.ToHashSet()
?? new HashSet<string>();
return true;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.Collections.Generic;
namespace Cthangover.Core.Items
{
/// <summary>
/// Concrete recipe fulfilling <c>IRecipe</c>. Populated by
/// <c>RecipeFactory.CreateRecipe</c> from a <c>RecipeInfo</c> DTO
/// after resolving every ingredient entry's item ID through
/// <c>ItemFactory</c>. Recipes are treated as immutable value
/// objects by the crafting UI — <c>RecipeFactory.Get</c> returns
/// the cached instance directly without copying.
/// </summary>
public class Recipe : IRecipe
{
/// <summary>Unique recipe ID (path-style, e.g. <c>"recipe/wolf_meat_to_ration"</c>).</summary>
public string ID { get; set; }
/// <summary>Locale key for the recipe display name.</summary>
public string Name { get; set; }
/// <summary>Locale key for the recipe description text.</summary>
public string Description { get; set; }
/// <summary>Crafting time in in-game minutes.</summary>
public int Time { get; set; }
/// <summary>Required crafting station (only <see cref="WorkbenchType.Cooking"/> exists).</summary>
public WorkbenchType WorkbenchType { get; set; }
/// <summary>Ingredients consumed on a successful craft; item references resolved by <see cref="Factories.Impls.RecipeFactory"/>.</summary>
public List<IIngredient> Input { get; set; }
/// <summary>Items produced on a successful craft.</summary>
public List<IIngredient> Output { get; set; }
}
}
+50
View File
@@ -0,0 +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();
}
}
}
+110
View File
@@ -0,0 +1,110 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Items;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Factories.Impls
{
/// <summary>
/// Factory for crafting recipes. Uses <c>BoundedCache</c> because the
/// crafting UI may repeatedly query recipes during scrolling and the
/// ingredient resolution (through <c>ItemFactory</c>) is non-trivial.
/// Recipes are treated as immutable value objects — <c>Get</c> returns
/// the cached instance directly without copying. Missing ingredient
/// items emit an error and skip that ingredient rather than failing the
/// whole recipe, so a mod author's typo in one input doesn't silently
/// break the crafting UI for unrelated recipes.
/// <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 RecipeFactory : ICacheLoader<string, IRecipe>
{
private static RecipeFactory _instance;
/// <summary>Static lazy singleton; kept as the public API.</summary>
public static RecipeFactory Instance => _instance ??= new RecipeFactory();
private Dictionary<string, RecipeInfo> _allInfos;
private readonly BoundedCache<string, IRecipe> _cache;
internal RecipeFactory()
{
var size = ModConfig.Instance.Cache.GetCacheSize("recipes", 128);
_cache = new BoundedCache<string, IRecipe>(size, this);
}
IRecipe ICacheLoader<string, IRecipe>.Load(string id)
{
var infos = EnsureInfos();
if (infos.TryGetValue(id, out var info))
return CreateRecipe(info);
return null;
}
/// <summary>
/// Returns the recipe definitions, collecting them on first use.
/// Publication to <c>_allInfos</c> waits until the mod registry is
/// finalized so an early lookup from the launcher boot window cannot
/// freeze an empty snapshot for the session.
/// </summary>
private Dictionary<string, RecipeInfo> EnsureInfos()
{
if (_allInfos != null)
return _allInfos;
var infos = Entities.Collect<RecipeInfo>();
if (ModManager.Instance.IsInitialized)
_allInfos = infos;
return infos;
}
/// <summary>
/// Returns the cached recipe for <paramref name="id"/> (resolved lazily
/// through <see cref="RecipeFactory.Load"/>), or <c>null</c> for unknown
/// or empty ids. The shared instance is returned without copying —
/// recipes are treated as immutable.
/// </summary>
public IRecipe Get(string id)
{
if (string.IsNullOrEmpty(id))
return null;
EnsureInfos();
return _cache.Get(id);
}
private static Recipe CreateRecipe(RecipeInfo info)
{
return new Recipe
{
ID = info.ID,
Name = info.Name,
Description = info.Description,
Time = info.Time,
WorkbenchType = info.Type,
Input = info.Input
.Select(i => CreateIngredient(i))
.Where(i => i != null)
.ToList(),
Output = info.Output
.Select(i => CreateIngredient(i))
.Where(i => i != null)
.ToList()
};
}
private static IIngredient CreateIngredient(IngredientInfo info)
{
var item = ItemFactory.Instance.Get(info.ID);
if (item == null)
{
GameLogger.Log("MOD_TEST", $"RecipeFactory: item '{info.ID}' not found in ItemFactory", LogLevel.Error);
return null;
}
return new Ingredient { Item = item, Count = info.Count };
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Cthangover.Core.Factories;
namespace Cthangover.Core.Items
{
/// <summary>
/// JSON DTO for recipe definitions in mod files. Uses a plain
/// <c>JsonStringEnumConverter</c> for <c>WorkbenchType</c> (simple
/// enum, no flags needed) rather than the
/// <c>FlagsStringEnumConverter</c> used by <c>ItemInfo</c>.
/// <c>Input</c> and <c>Output</c> are raw <c>IngredientInfo</c>
/// lists — the actual <c>IItem</c> references are resolved by
/// <c>RecipeFactory</c> at cache population time, which also
/// validates that every referenced item exists in <c>ItemFactory</c>.
/// </summary>
[Serializable]
[EntityKind("recipe")]
public class RecipeInfo : IIdentifiable
{
/// <summary>Unique recipe ID, serialized as "Id" in JSON.</summary>
[JsonPropertyName("Id")]
public string ID { get; set; }
/// <summary>Locale key for the recipe display name.</summary>
public string Name { get; set; }
/// <summary>Locale key for the recipe description text.</summary>
public string Description { get; set; }
/// <summary>Required crafting station, serialized as its enum name ("Cooking").</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public WorkbenchType Type { get; set; }
/// <summary>Crafting time in in-game minutes.</summary>
public int Time { get; set; }
/// <summary>Raw ingredient entries; item references are resolved and validated by <see cref="Factories.Impls.RecipeFactory"/>.</summary>
public List<IngredientInfo> Input { get; set; }
/// <summary>Raw output entries resolved at factory time.</summary>
public List<IngredientInfo> Output { get; set; }
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace Cthangover.Mods.Items
{
/// <summary>
/// Migration marker for the coverage pipeline
/// (<c>SubsystemsBridge</c> reflection scan): declares the items
/// subsystem fully moved out of the core assembly into this engine mod.
/// The id must match the <c>DeclaredTests</c> entry in
/// <c>SubsystemsCoverageTestSetup</c>.
/// </summary>
public static class SubsystemRegistration
{
public static string SubsystemId => "items";
}
}
+183
View File
@@ -0,0 +1,183 @@
using System;
using Cthangover.Core.Characters;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Core.UI.Inventory
{
/// <summary>
/// Inline target picker for <see cref="ItemType.TargetUsed"/> items,
/// embedded directly into the item passport instead of replacing the
/// whole right column. Each party member is a button row with a health
/// bar, current/max HP and the translated name; a cancel button closes
/// the picker. Extends <see cref="VBoxContainer"/> because the previous
/// Control-based version left its children unmanaged — a plain Control
/// never laid them out, so title, rows and cancel button collapsed into
/// the top-left corner.
/// </summary>
public partial class CharacterTargetPanel : VBoxContainer
{
/// <summary>Raised when the player picks a target; the effect is applied by the inventory panel.</summary>
public event Action<CharacterInfoData> CharacterSelected;
/// <summary>Raised when the player aborts target selection.</summary>
public event Action Cancelled;
/// <summary>Builds the picker inline: title, one row per party member, and a cancel button.</summary>
public CharacterTargetPanel()
{
SizeFlagsVertical = SizeFlags.ExpandFill;
MouseFilter = MouseFilterEnum.Stop;
AddThemeConstantOverride("separation", 7);
var title = new Label();
title.Text = InventoryItemUi.Translate("ui/inventory/select_target").ToUpperInvariant();
title.AddThemeFontSizeOverride("font_size", 10);
title.AddThemeColorOverride("font_color", UiTokens.TypeTarget);
title.MouseFilter = MouseFilterEnum.Ignore;
AddChild(title);
var characterData = GameData.Instance.Runtime.CharacterData;
foreach (var id in characterData.BattleSet)
{
if (!characterData.Characters.TryGetValue(id, out var info))
continue;
AddChild(BuildTargetRow(info));
}
var cancel = new Button();
cancel.Text = InventoryItemUi.Translate("ui/inventory/cancel");
cancel.FocusMode = FocusModeEnum.None;
cancel.AddThemeFontSizeOverride("font_size", 13);
cancel.AddThemeStyleboxOverride("normal", MakeCancelStyle(false));
cancel.AddThemeStyleboxOverride("hover", MakeCancelStyle(true));
cancel.AddThemeStyleboxOverride("pressed", MakeCancelStyle(true));
cancel.AddThemeStyleboxOverride("focus", new StyleBoxEmpty());
cancel.AddThemeColorOverride("font_color", UiTokens.Body);
cancel.AddThemeColorOverride("font_hover_color", Colors.White);
cancel.Pressed += OnCancelPressed;
AddChild(cancel);
}
private Button BuildTargetRow(CharacterInfoData info)
{
var template = CharacterTemplateRegistry.Get(info.ID);
var displayName = InventoryItemUi.Translate(template?.Name ?? info.ID);
var health = info.Attributes?.Health;
var value = health?.Value ?? 0;
var max = health?.BaseValue ?? 0;
var button = new Button();
button.FocusMode = FocusModeEnum.None;
button.CustomMinimumSize = new Vector2(0, 46);
button.SizeFlagsHorizontal = SizeFlags.ExpandFill;
button.AddThemeStyleboxOverride("normal", MakeRowStyle(false));
button.AddThemeStyleboxOverride("hover", MakeRowStyle(true));
button.AddThemeStyleboxOverride("pressed", MakeRowStyle(true));
button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty());
var row = new HBoxContainer();
row.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
row.OffsetLeft = 12;
row.OffsetRight = -12;
row.AddThemeConstantOverride("separation", 10);
row.MouseFilter = MouseFilterEnum.Ignore;
button.AddChild(row);
var name = new Label();
name.Text = displayName;
name.AddThemeFontSizeOverride("font_size", 14);
name.AddThemeColorOverride("font_color", UiTokens.Ink);
name.TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis;
name.ClipText = true;
name.SizeFlagsHorizontal = SizeFlags.ExpandFill;
name.VerticalAlignment = VerticalAlignment.Center;
name.MouseFilter = MouseFilterEnum.Ignore;
row.AddChild(name);
row.AddChild(BuildHealthBar(value, max));
var hp = new Label();
hp.Text = value + "/" + max;
hp.AddThemeFontSizeOverride("font_size", 12);
hp.AddThemeColorOverride("font_color", UiTokens.Muted);
hp.VerticalAlignment = VerticalAlignment.Center;
hp.MouseFilter = MouseFilterEnum.Ignore;
row.AddChild(hp);
var captured = info;
button.Pressed += () => CharacterSelected?.Invoke(captured);
return button;
}
private void OnCancelPressed()
{
Cancelled?.Invoke();
}
private static ProgressBar BuildHealthBar(int value, int max)
{
var ratio = max > 0 ? (float)value / max : 1f;
var bar = new ProgressBar();
bar.CustomMinimumSize = new Vector2(96, 7);
bar.MaxValue = max > 0 ? max : 1;
bar.Value = value;
bar.ShowPercentage = false;
bar.MouseFilter = MouseFilterEnum.Ignore;
bar.AddThemeStyleboxOverride("background", MakeBarStyle(UiTokens.LineDisabled));
bar.AddThemeStyleboxOverride("fill", MakeBarStyle(ratio < 0.4f
? UiTokens.HpLow
: UiTokens.HpOk));
return bar;
}
private static StyleBoxFlat MakeRowStyle(bool hover)
{
var style = new StyleBoxFlat();
style.BgColor = hover ? UiTokens.TargetHover : UiTokens.Surface2;
style.BorderColor = hover ? UiTokens.TargetLine : UiTokens.Line;
style.BorderWidthLeft = 1;
style.BorderWidthRight = 1;
style.BorderWidthTop = 1;
style.BorderWidthBottom = 1;
style.CornerRadiusTopLeft = 6;
style.CornerRadiusTopRight = 6;
style.CornerRadiusBottomLeft = 6;
style.CornerRadiusBottomRight = 6;
return style;
}
private static StyleBoxFlat MakeBarStyle(Color color)
{
var style = new StyleBoxFlat();
style.BgColor = color;
style.CornerRadiusTopLeft = 4;
style.CornerRadiusTopRight = 4;
style.CornerRadiusBottomLeft = 4;
style.CornerRadiusBottomRight = 4;
return style;
}
private static StyleBoxFlat MakeCancelStyle(bool hover)
{
var style = new StyleBoxFlat();
style.BgColor = hover ? UiTokens.BtnHover : UiTokens.Surface3;
style.BorderColor = hover ? UiTokens.Alpha(UiTokens.Accent, 0.55f) : UiTokens.Line;
style.BorderWidthLeft = 1;
style.BorderWidthRight = 1;
style.BorderWidthTop = 1;
style.BorderWidthBottom = 1;
style.CornerRadiusTopLeft = 4;
style.CornerRadiusTopRight = 4;
style.CornerRadiusBottomLeft = 4;
style.CornerRadiusBottomRight = 4;
style.ContentMarginLeft = 12;
style.ContentMarginRight = 12;
style.ContentMarginTop = 9;
style.ContentMarginBottom = 9;
return style;
}
}
}
+41
View File
@@ -0,0 +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;
}
}
}
+244
View File
@@ -0,0 +1,244 @@
using System;
using Cthangover.Core.Items;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Core.UI.Inventory
{
/// <summary>
/// Interactive cell of the inventory grid. Unlike the old thin cell that
/// only drew an icon and a stack counter, this one owns the whole visual
/// language of a slot: the frame (normal/hover/selected), the type accent
/// dot, the short action tag and the dimmed state for inert items. It is
/// built entirely in code because the grid needs pixel-accurate
/// <see cref="StyleBoxFlat"/> frame swapping that theme overrides in the
/// tscn pipeline cannot express.
///
/// Click handling lives inside the cell: it raises <see cref="Clicked"/>
/// after consuming the mouse event, instead of the panel subscribing to
/// <c>GuiInput</c> from outside, so event propagation is stopped by the
/// control that actually owns the click.
/// </summary>
public partial class InventoryItemBehaviour : ListItem<IItemContainer>
{
/// <summary>Raised on a left-click after the cell consumed the event. The payload is the cell itself.</summary>
public event Action<InventoryItemBehaviour> Clicked;
private static readonly StyleBoxFlat NormalFrame = MakeFrame(UiTokens.LineSoft, UiTokens.Surface1);
private static readonly StyleBoxFlat HoverFrame = MakeFrame(UiTokens.LineStrong, UiTokens.SurfaceHover);
private static readonly StyleBoxFlat SelectedFrame = MakeFrame(UiTokens.Accent, UiTokens.SurfaceSelected);
private static readonly StyleBoxFlat TagStyle = MakeTagStyle();
private static readonly Color IconDimmed = UiTokens.Alpha(UiTokens.White, 0.38f);
private Panel _frame;
private TextureRect _icon;
private Label _count;
private Panel _dot;
private StyleBoxFlat _dotStyle;
private PanelContainer _tag;
private Label _tagLabel;
private bool _built;
private bool _selected;
private bool _hovered;
/// <summary>Stops mouse propagation and wires hover/click handling before any model is bound.</summary>
public InventoryItemBehaviour()
{
MouseFilter = MouseFilterEnum.Stop;
GuiInput += OnGuiInput;
MouseEntered += OnMouseEntered;
MouseExited += OnMouseExited;
}
/// <summary>
/// Binds the item stack and lazily builds the visual tree on first
/// use; repeated binds only refresh the rendered data.
/// </summary>
public override void Construct(IItemContainer model)
{
base.Construct(model);
EnsureBuilt();
UpdateInfo();
}
/// <summary>
/// Pushes the bound stack into the visuals: icon, stack counter
/// (hidden for singletons), type accent dot, action tag and the
/// dimmed state for items without a usable action.
/// </summary>
public void UpdateInfo()
{
if (!_built || Model?.Item == null)
return;
var item = Model.Item;
_icon.Texture = item.Sprite;
_count.Text = Model.Count > 1 ? "×" + Model.Count : string.Empty;
_count.Visible = Model.Count > 1;
_dotStyle.BgColor = InventoryItemUi.AccentColor(item.ItemType);
var tagKey = InventoryItemUi.TagKey(item);
_tag.Visible = tagKey != null;
if (tagKey != null)
_tagLabel.Text = InventoryItemUi.Translate(tagKey);
var dimmed = !InventoryItemUi.IsUsable(item);
_icon.Modulate = dimmed ? IconDimmed : Colors.White;
_count.Modulate = dimmed ? IconDimmed : Colors.White;
}
/// <summary>Switches the frame to the gold selected state; the panel owns this flag.</summary>
public void SetSelected(bool selected)
{
_selected = selected;
ApplyFrame();
}
/// <summary>Removes the cell from the scene tree; the grid rebuilds cells instead of recycling them.</summary>
public override void Destruct()
{
QueueFree();
}
private void EnsureBuilt()
{
if (_built)
return;
_built = true;
_frame = new Panel();
_frame.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_frame.AddThemeStyleboxOverride("panel", NormalFrame);
_frame.MouseFilter = MouseFilterEnum.Ignore;
AddChild(_frame);
_dot = new Panel();
_dot.SetAnchorsAndOffsetsPreset(LayoutPreset.TopLeft);
_dot.OffsetLeft = 6;
_dot.OffsetTop = 6;
_dot.OffsetRight = 13;
_dot.OffsetBottom = 13;
_dot.MouseFilter = MouseFilterEnum.Ignore;
_dotStyle = new StyleBoxFlat();
_dotStyle.CornerRadiusTopLeft = 2;
_dotStyle.CornerRadiusTopRight = 2;
_dotStyle.CornerRadiusBottomLeft = 2;
_dotStyle.CornerRadiusBottomRight = 2;
_dot.AddThemeStyleboxOverride("panel", _dotStyle);
AddChild(_dot);
_icon = new TextureRect();
_icon.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_icon.OffsetLeft = 12;
_icon.OffsetTop = 12;
_icon.OffsetRight = -12;
_icon.OffsetBottom = -12;
_icon.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
_icon.StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered;
_icon.MouseFilter = MouseFilterEnum.Ignore;
AddChild(_icon);
_count = new Label();
_count.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomRight);
_count.OffsetLeft = -70;
_count.OffsetTop = -26;
_count.OffsetRight = -6;
_count.OffsetBottom = -3;
_count.HorizontalAlignment = HorizontalAlignment.Right;
_count.VerticalAlignment = VerticalAlignment.Center;
_count.AddThemeFontSizeOverride("font_size", 13);
_count.AddThemeColorOverride("font_color", UiTokens.Stack);
_count.AddThemeColorOverride("font_outline_color", new Color(0f, 0f, 0f, 0.9f));
_count.AddThemeConstantOverride("outline_size", 4);
_count.MouseFilter = MouseFilterEnum.Ignore;
AddChild(_count);
var tagRow = new HBoxContainer();
tagRow.SetAnchorsAndOffsetsPreset(LayoutPreset.TopWide);
tagRow.OffsetLeft = 5;
tagRow.OffsetTop = 4;
tagRow.OffsetRight = -5;
tagRow.OffsetBottom = 24;
tagRow.Alignment = BoxContainer.AlignmentMode.End;
tagRow.MouseFilter = MouseFilterEnum.Ignore;
AddChild(tagRow);
_tag = new PanelContainer();
_tag.AddThemeStyleboxOverride("panel", TagStyle);
_tag.MouseFilter = MouseFilterEnum.Ignore;
tagRow.AddChild(_tag);
_tagLabel = new Label();
_tagLabel.AddThemeFontSizeOverride("font_size", 9);
_tagLabel.AddThemeColorOverride("font_color", UiTokens.AccentBright);
_tagLabel.MouseFilter = MouseFilterEnum.Ignore;
_tag.AddChild(_tagLabel);
}
private void OnGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
{
Clicked?.Invoke(this);
AcceptEvent();
}
}
private void OnMouseEntered()
{
_hovered = true;
ApplyFrame();
}
private void OnMouseExited()
{
_hovered = false;
ApplyFrame();
}
private void ApplyFrame()
{
if (!_built || _frame == null)
return;
_frame.AddThemeStyleboxOverride("panel",
_selected ? SelectedFrame : (_hovered ? HoverFrame : NormalFrame));
}
private static StyleBoxFlat MakeFrame(Color border, Color background)
{
var style = new StyleBoxFlat();
style.BgColor = background;
style.BorderColor = border;
style.BorderWidthLeft = 1;
style.BorderWidthRight = 1;
style.BorderWidthTop = 1;
style.BorderWidthBottom = 1;
style.CornerRadiusTopLeft = 6;
style.CornerRadiusTopRight = 6;
style.CornerRadiusBottomLeft = 6;
style.CornerRadiusBottomRight = 6;
return style;
}
private static StyleBoxFlat MakeTagStyle()
{
var style = new StyleBoxFlat();
style.BgColor = UiTokens.SlotTagBg;
style.BorderColor = UiTokens.AccentLine;
style.BorderWidthLeft = 1;
style.BorderWidthRight = 1;
style.BorderWidthTop = 1;
style.BorderWidthBottom = 1;
style.CornerRadiusTopLeft = 3;
style.CornerRadiusTopRight = 3;
style.CornerRadiusBottomLeft = 3;
style.CornerRadiusBottomRight = 3;
style.ContentMarginLeft = 5;
style.ContentMarginRight = 5;
style.ContentMarginTop = 1;
style.ContentMarginBottom = 1;
return style;
}
}
}
+141
View File
@@ -0,0 +1,141 @@
using System.Collections.Generic;
using Cthangover.Core.Items;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Core.UI.Inventory
{
/// <summary>
/// Shared presentation rules for inventory items, used by both the grid
/// cell and the item passport so the two can never disagree about an
/// item's affordances. <see cref="ItemType"/> is a flags enum, so every
/// question ("can this be used?", "what badge colour?") has to pick a
/// priority among possibly overlapping flags — the order in
/// <see cref="BadgeOrder"/> defines that priority consistently.
/// A custom verb can be supplied by mods through the item's
/// <c>Properties</c> bag under the <c>verb</c> key; it is treated as a
/// locale key when it contains a slash and as a literal otherwise.
/// </summary>
public static class InventoryItemUi
{
/// <summary>Badge order from most narrative to most technical flag.</summary>
public static readonly IReadOnlyList<ItemType> BadgeOrder = new[]
{
ItemType.Quest,
ItemType.Used,
ItemType.TargetUsed,
ItemType.Food,
ItemType.Resource,
ItemType.Recipe,
ItemType.CantDrop,
};
/// <summary>
/// Mirrors <c>InventoryPanelBehaviour</c> use-button rule: an item is
/// usable only when it carries an action and one of the "used" flags.
/// Food or resources without an <see cref="IItemAction"/> are inert.
/// </summary>
public static bool IsUsable(IItem item)
{
if (item == null || item.ItemAction == null)
return false;
return item.ItemType.HasFlag(ItemType.Used) || item.ItemType.HasFlag(ItemType.TargetUsed);
}
/// <summary>False only for <see cref="ItemType.CantDrop"/> items (quest objectives, key items).</summary>
public static bool CanDrop(IItem item)
{
return item != null && !item.ItemType.HasFlag(ItemType.CantDrop);
}
/// <summary>
/// Locale key (or literal) of the verb shown on the action button.
/// Resolution order: mod-provided <c>verb</c> property, recipe,
/// targeted, food, generic use.
/// </summary>
public static string VerbKey(IItem item)
{
if (item == null)
return "ui/inventory/verb_use";
var custom = item.Properties?.GetStr("verb");
if (!string.IsNullOrWhiteSpace(custom))
return custom;
if (item.ItemType.HasFlag(ItemType.Recipe))
return "ui/inventory/verb_recipe";
if (item.ItemType.HasFlag(ItemType.TargetUsed))
return "ui/inventory/verb_target";
if (item.ItemType.HasFlag(ItemType.Food))
return "ui/inventory/verb_eat";
return "ui/inventory/verb_use";
}
/// <summary>
/// Short corner tag for the grid cell. Only actionable items get a
/// tag — an inert resource must not advertise a verb it cannot run.
/// </summary>
public static string TagKey(IItem item)
{
if (item?.ItemAction == null)
return null;
if (item.ItemType.HasFlag(ItemType.Recipe))
return "ui/inventory/tag_recipe";
if (item.ItemType.HasFlag(ItemType.TargetUsed))
return "ui/inventory/tag_target";
if (item.ItemType.HasFlag(ItemType.Food))
return "ui/inventory/tag_food";
return "ui/inventory/tag_use";
}
/// <summary>
/// Accent colour of an item: quest gold dominates, then recipe,
/// food, targeted, used and finally plain resources.
/// </summary>
public static Color AccentColor(ItemType type)
{
if (type.HasFlag(ItemType.Quest))
return UiTokens.TypeQuest;
if (type.HasFlag(ItemType.Recipe))
return UiTokens.TypeRecipe;
if (type.HasFlag(ItemType.Food))
return UiTokens.TypeFood;
if (type.HasFlag(ItemType.TargetUsed))
return UiTokens.TypeTarget;
if (type.HasFlag(ItemType.Used))
return UiTokens.TypeUsed;
if (type.HasFlag(ItemType.Resource))
return UiTokens.TypeResource;
return UiTokens.Muted;
}
/// <summary>Locale key of the badge caption for a single type flag.</summary>
public static string TypeBadgeKey(ItemType type)
{
if (type == ItemType.Quest)
return "ui/inventory/type_quest";
if (type == ItemType.Used)
return "ui/inventory/type_used";
if (type == ItemType.TargetUsed)
return "ui/inventory/type_target";
if (type == ItemType.Food)
return "ui/inventory/type_food";
if (type == ItemType.Resource)
return "ui/inventory/type_resource";
if (type == ItemType.Recipe)
return "ui/inventory/type_recipe";
if (type == ItemType.CantDrop)
return "ui/inventory/type_cant_drop";
return null;
}
/// <summary>Translates a locale key, falling back to the key itself for unknown keys and literals.</summary>
public static string Translate(string keyOrLiteral)
{
if (string.IsNullOrEmpty(keyOrLiteral))
return string.Empty;
return TranslationServer.Translate(keyOrLiteral).ToString();
}
}
}
File diff suppressed because it is too large. Load diff
+10
View File
@@ -0,0 +1,10 @@
{
"name": "Inventory Test",
"description": "Integration test mod: drives the inventory GUI end-to-end (open panel, seed items via public API, verify grid, click a cell, search filter, close) and reports [TEST_RESULT]. Activated by --itest=inventory user arg.",
"sources": [
"src/*.cs"
],
"depends": [
"visual_novel_core>=1"
]
}
@@ -0,0 +1,314 @@
using System.Collections.Generic;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Settings;
using Cthangover.Core.UI;
using Cthangover.Core.Utils;
using Godot;
namespace Mods.InventoryTest
{
/// <summary>
/// Драйвер интеграционного GUI-теста инвентаря. Проходит по реальному UI
/// теми же контрактами, что и игрок: открывает панель через публичный
/// <see cref="UiRegistry.Toggle"/> (тот же путь, что кнопка тулбара),
/// сеет детерминированные предметы через публичный <c>IInventory.Add</c>,
/// кликает ячейку эмуляцией Godot-сигнала <c>gui_input</c> (обработчик
/// ячейки подписан на него — мод не может переопределять виртуальные
/// методы, но сигналы из мод-сборок работают), фильтрует через
/// <c>LineEdit</c>-сигнал <c>text_changed</c> и закрывает панель.
///
/// Машина состояний: один шаг за тик таймера (0.4с), чтобы отложенные
/// пересборки сетки успевали примениться. Каждый шаг — отдельная
/// проверка: исход фиксируется, провал не прерывает остальные. Итог —
/// контракт раннера <c>[TEST_RESULT] name=inventory_test passed=N
/// failed=M</c> и код выхода процесса. Общий watchdog
/// (<c>--timeout=</c>, по умолчанию 60с) гасит зависание с failed=1 —
/// тем же контрактом, что и у DialogAutoDriverNode.
/// </summary>
public partial class InventoryTestDriverNode : Node
{
private const string ResultName = "inventory_test";
private const string PanelEntryId = "inventory";
private const float TickSeconds = 0.4f;
private const float DefaultTimeoutSeconds = 60f;
private readonly float? timeoutSeconds;
private readonly List<string> failures = new();
private int passed;
private int failed;
private int step;
private double elapsed;
private Control panel;
private Control gridContent;
private LineEdit search;
/// <summary>Создаёт драйвер с watchdog-таймаутом (null — дефолт 60с) и ставит тикающий таймер-ребёнок.</summary>
public InventoryTestDriverNode(float? timeoutSeconds)
{
this.timeoutSeconds = timeoutSeconds;
var timer = new Timer
{
Name = "TickTimer",
WaitTime = TickSeconds,
Autostart = true,
OneShot = false
};
AddChild(timer);
timer.Timeout += Tick;
}
/// <summary>Один шаг машины состояний: ожидание готовности, затем проверки по очереди.</summary>
private void Tick()
{
if (step < 0)
return; // тест уже завершён
elapsed += TickSeconds;
var limit = timeoutSeconds ?? DefaultTimeoutSeconds;
if (elapsed >= limit)
{
Finish($"timeout ({limit:0}s) reached at step {step}");
return;
}
try
{
RunStep();
}
catch (System.Exception ex)
{
failures.Add($"step {step}: unhandled {ex.GetType().Name}: {ex.Message}");
failed++;
Log($"[FAIL] step {step}: {ex.Message}");
Finish(null);
}
}
private void RunStep()
{
// Шаг 0: ждём готовности — бут завершён и сцена вошла в дерево.
if (step == 0)
{
var ready = Boot.IsReady
&& GameData.Instance?.Runtime != null
&& (Godot.Engine.GetMainLoop() as SceneTree)?.CurrentScene != null;
if (!ready)
return; // ждём дальше, watchdog страхует
Log("environment ready (boot complete, scene entered)");
Next();
return;
}
// Шаг 1: UI-запись инвентаря зарегистрирована модом visual_novel_core.
if (step == 1)
{
Check("entry registered", () =>
{
var entry = AppServices.Current.Ui.GetById(PanelEntryId);
if (entry == null)
throw new CheckException($"ui entry '{PanelEntryId}' not found");
});
Next();
return;
}
// Шаг 2: сеем детерминированные предметы через публичный API
// инвентаря (в этой сессии; сохранения тест не пишет).
if (step == 2)
{
Check("inventory seed via IInventory.Add", () =>
{
var inv = GameData.Instance.Runtime.Inventory;
if (inv == null)
throw new CheckException("Runtime.Inventory is null (items mod factory missing?)");
inv.Add("food/ration", 3);
inv.Add("food/wolf_meat", 2);
if (inv.CheckCount("food/ration") < 3)
throw new CheckException("food/ration count < 3 after Add");
if (inv.CheckCount("food/wolf_meat") < 2)
throw new CheckException("food/wolf_meat count < 2 after Add");
});
Next();
return;
}
// Шаг 3: открываем панель тем же вызовом, что и кнопка тулбара.
if (step == 3)
{
Check("open via UiRegistry.Toggle", () =>
{
UiRegistry.Toggle(PanelEntryId);
if (!UiHost.Instance.TryGetInstance(PanelEntryId, out panel) || panel == null)
throw new CheckException("panel instance not created by Toggle");
if (!panel.Visible)
throw new CheckException("panel not visible after Toggle");
});
Next();
return;
}
// Шаг 4: сетка отрисовала засеянные предметы.
if (step == 4)
{
Check("grid renders seeded items", () =>
{
gridContent = panel.FindChild("GridContent", true, false) as Control;
if (gridContent == null)
throw new CheckException("GridContent node not found");
// Один кадр на пересборку после Add: если ячейки ещё не
// успели, тик-ждать нельзя (шаг уже выполняется), поэтому
// сравниваем с запасом на асинхронность через событие Change,
// которое панель обрабатывает синхронно.
if (gridContent.GetChildCount() < 2)
throw new CheckException($"grid has {gridContent.GetChildCount()} cell(s), expected >= 2");
});
Next();
return;
}
// Шаг 5: клик по первой ячейке — эмуляция gui_input, как будто
// щёлкнул игрок; панель выбирает предмет и наполняет паспорт.
if (step == 5)
{
Check("cell click fills passport", () =>
{
var cell = gridContent.GetChildCount() > 0 ? gridContent.GetChild(0) as Control : null;
if (cell == null)
throw new CheckException("no cell to click");
var click = new InputEventMouseButton
{
ButtonIndex = MouseButton.Left,
Pressed = true
};
cell.EmitSignal("gui_input", click);
var passport = panel.FindChild("Passport", true, false);
if (passport == null)
throw new CheckException("Passport node not found");
var hasText = false;
foreach (var child in passport.FindChildren("*", "Label", true, false))
{
if (child is Label l && !string.IsNullOrWhiteSpace(l.Text))
{
hasText = true;
break;
}
}
if (!hasText)
throw new CheckException("passport has no non-empty label after selection");
});
Next();
return;
}
// Шаг 6: поиск-фильтр через сигнал LineEdit. Старые ячейки сетка
// удаляет через QueueFree — они исчезают лишь в конце кадра, поэтому
// в этом же тике проверяем синхронный индикатор ShownLabel
// ("показано 0"), а количество нод — на следующем тике.
if (step == 6)
{
Check("no-match filter zeroes the shown counter", () =>
{
search = panel.FindChild("Search", true, false) as LineEdit;
if (search == null)
throw new CheckException("Search LineEdit not found");
search.Text = "zzz_no_such_item";
search.EmitSignal("text_changed", search.Text);
var shown = panel.FindChild("ShownLabel", true, false) as Label;
var text = shown?.Text ?? "";
if (!text.Contains("0"))
throw new CheckException($"ShownLabel '{text}' does not show zero after no-match filter");
});
Next();
return;
}
// Шаг 7: кадр прошёл — ячейки после no-match фильтра реально
// удалены из дерева; затем возвращаем фильтр в пустое значение.
if (step == 7)
{
Check("grid empties after filter, restores after reset", () =>
{
var after = gridContent.GetChildCount();
if (after != 0)
throw new CheckException($"grid has {after} cell(s) after no-match filter, expected 0");
search.Text = "";
search.EmitSignal("text_changed", search.Text);
});
Next();
return;
}
// Шаг 8: сетка восстановлена; повторный Toggle закрывает панель.
if (step == 8)
{
Check("grid restored and second Toggle closes", () =>
{
var restored = gridContent.GetChildCount();
if (restored < 2)
throw new CheckException($"grid has {restored} cell(s) after filter reset, expected >= 2");
UiRegistry.Toggle(PanelEntryId);
if (panel.Visible)
throw new CheckException("panel still visible after second Toggle");
});
Finish(null);
}
}
private void Next()
{
step++;
}
private void Check(string title, System.Action body)
{
var sw = Godot.Time.GetTicksUsec();
try
{
body();
passed++;
Log($"[PASS] {title} ({(Godot.Time.GetTicksUsec() - sw) / 1000.0:0.0} ms)");
}
catch (CheckException ex)
{
failed++;
failures.Add($"{title}: {ex.Message}");
Log($"[FAIL] {title}: {ex.Message}");
}
}
/// <summary>Печатает итог и завершает процесс; после вызова шаги не выполняются.</summary>
private void Finish(string reason)
{
if (step < 0)
return;
step = -1;
if (reason != null)
{
failed++;
Log($"[FAIL] {reason}");
}
foreach (var f in failures)
Log($" failed: {f}");
Log($"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");
GetTree().Quit(failed == 0 ? 0 : 1);
}
private void Log(string message)
{
GameLogger.Log("TEST", $"InventoryTest: {message}");
}
/// <summary>Отличает ожидаемую проверку от неожиданного исключения шага.</summary>
private sealed class CheckException : System.Exception
{
public CheckException(string message) : base(message) { }
}
}
}
@@ -0,0 +1,62 @@
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Mods.InventoryTest
{
/// <summary>
/// Точка входа интеграционного теста инвентаря (прецедент live2d_test:
/// мод сам везёт драйвер и сам печатает [TEST_RESULT]). Активация —
/// только по явному пользовательскому флагу <c>--itest=inventory</c>
/// (user-args после --), чтобы мод никогда не вмешивался в обычную игру и
/// в другие смоук-прогоны. Само переключение сцен выполняет ядро
/// (<c>--scene=</c>), драйверу остаётся только дождаться готового шелла.
/// Драйвер ставится в корень дерева отложенно: <c>OnModResourcesReady</c>
/// срабатывает в фазе Scenes, до окончания бута и до входа в сцену.
/// </summary>
public class InventoryTestInitializer : IModInitializer
{
private static bool driverQueued;
/// <summary>Per-mod инициализация не требуется — драйвер ставится на resources-ready.</summary>
public void OnModLoaded(string modId) { }
/// <summary>
/// Разбирает user-args; при активационном флаге добавляет драйвер
/// теста в корень дерева. Повторный <c>ModManager.Reload()</c> не
/// должен плодить драйверы — защищено статическим флагом.
/// </summary>
public void OnModResourcesReady()
{
if (driverQueued)
return;
var args = OS.GetCmdlineUserArgs();
// Ровно свой флаг: широкая проверка StartsWith("--itest=") активировала
// бы этот тест и при запуске чужого itest-мода, а его драйвер квитит
// процесс в конце — второй тест не успевал бы отработать.
if (!args.Any(a => a == "--itest=inventory"))
return;
var timeout = ParseTimeout(args);
driverQueued = true;
GameLogger.Log("TEST", $"InventoryTestInitializer: activating inventory GUI test, timeout={timeout?.ToString() ?? "none"}s");
// Deferred: сцена ещё не готова; драйвер сам ждёт Boot.IsReady и
// появления CurrentScene, поэтому порядок с загрузкой сцены не важен.
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new InventoryTestDriverNode(timeout));
}
private static float? ParseTimeout(string[] args)
{
var raw = args
.Where(a => a.StartsWith("--timeout=", System.StringComparison.Ordinal))
.Select(a => a.Substring("--timeout=".Length))
.FirstOrDefault();
return float.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, out var t) && t > 0
? t
: null;
}
}
}