diff --git a/mod.jpg b/mod.jpg
new file mode 100644
index 0000000..901019a
Binary files /dev/null and b/mod.jpg differ
diff --git a/src/HealItemAction.cs b/src/HealItemAction.cs
new file mode 100644
index 0000000..2b077bd
--- /dev/null
+++ b/src/HealItemAction.cs
@@ -0,0 +1,43 @@
+using System;
+using Cthangover.Core.Items;
+using Cthangover.Core.Settings;
+
+namespace Cthangover.Mods.Items
+{
+ ///
+ /// Restores health of the chosen party member ("ItemAction": "action/heal").
+ /// The amount is item data, not code: the item's "heal" key (an extra JSON
+ /// key, so it lands in ), which lets one action serve
+ /// a mushroom and an elixir alike. Health never rises above its base value, and a
+ /// fallen character (health 0) is not revived — that is a different effect.
+ /// The party member's attributes are the very objects the battle uses, so a heal
+ /// in the inventory during a battle shows on the battle widgets at once.
+ ///
+ public class HealItemAction : ITargetedItemAction
+ {
+ /// Item property with the amount of health restored.
+ public const string HealProperty = "heal";
+
+ /// Action id referenced by item definitions.
+ public string ID => "action/heal";
+
+ /// Healing always needs a target; without one nothing happens.
+ public bool UseAction(IItem item) => false;
+
+ ///
+ /// Adds the item's heal amount to the target's health, capped at the
+ /// base value. Returns false (item not spent) for a missing target, a
+ /// non-positive amount, a fallen target or one already at full health.
+ ///
+ public bool UseOn(IItem item, CharacterInfoData target)
+ {
+ var health = target?.Attributes?.Health;
+ var amount = item?.Properties?.GetInt(HealProperty) ?? 0;
+ if (health == null || amount <= 0 || health.Value <= 0 || health.Value >= health.BaseValue)
+ return false;
+
+ health.Value = Math.Min(health.BaseValue, health.Value + amount);
+ return true;
+ }
+ }
+}
diff --git a/src/ITargetedItemAction.cs b/src/ITargetedItemAction.cs
new file mode 100644
index 0000000..677aa7e
--- /dev/null
+++ b/src/ITargetedItemAction.cs
@@ -0,0 +1,23 @@
+using Cthangover.Core.Items;
+using Cthangover.Core.Settings;
+
+namespace Cthangover.Mods.Items
+{
+ ///
+ /// Item action that needs to know whom it is applied to. The core
+ /// contract has no target, so an effect on a party
+ /// member (healing) could not tell which member was picked in
+ /// . Items with
+ /// whose action implements this interface get
+ /// with the chosen character; other actions keep receiving
+ /// the plain .
+ ///
+ public interface ITargetedItemAction : IItemAction
+ {
+ ///
+ /// Applies the item to . Returns false when
+ /// nothing happened (e.g. healing at full health), so the item is not spent.
+ ///
+ bool UseOn(IItem item, CharacterInfoData target);
+ }
+}
diff --git a/src/ItemAddAction.cs b/src/ItemAddAction.cs
new file mode 100644
index 0000000..3bc9230
--- /dev/null
+++ b/src/ItemAddAction.cs
@@ -0,0 +1,42 @@
+using Cthangover.Core.Actions;
+
+namespace Cthangover.Mods.Items
+{
+ ///
+ /// action item.add id=resource/branches [count=3] — puts items into the
+ /// player's inventory from a scenario: a chest, a gift, a pickup on an RPG map.
+ /// count defaults to 1; an unknown item id is reported by the inventory
+ /// and adds nothing.
+ ///
+ public class ItemAddAction : IScenarioAction
+ {
+ /// DSL name.
+ public string Name => "item.add";
+
+ /// Reads id and count and adds the stack.
+ public void Run(IActionContext ctx)
+ {
+ var id = ctx.GetParam("id");
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ ctx.LogWarning("INVENTORY", "item.add: missing 'id'");
+ return;
+ }
+
+ var countText = ctx.GetParam("count");
+ var count = 1;
+ if (!string.IsNullOrWhiteSpace(countText) && (!int.TryParse(countText, out count) || count <= 0))
+ {
+ ctx.LogWarning("INVENTORY", $"item.add: 'count' must be a positive number, got '{countText}'");
+ return;
+ }
+
+ if (ctx.Inventory == null)
+ {
+ ctx.LogWarning("INVENTORY", "item.add: no inventory in this game");
+ return;
+ }
+ ctx.Inventory.Add(id.Trim(), count);
+ }
+ }
+}
diff --git a/src/ui/InventoryItemBehaviour.cs b/src/ui/InventoryItemBehaviour.cs
index 6d9eb33..f2b92c4 100644
--- a/src/ui/InventoryItemBehaviour.cs
+++ b/src/ui/InventoryItemBehaviour.cs
@@ -88,6 +88,23 @@ namespace Cthangover.Core.UI.Inventory
_count.Modulate = dimmed ? IconDimmed : Colors.White;
}
+ ///
+ /// Overrides what derived from the item's action,
+ /// for grids where the action is not the point — the trade window shows a
+ /// price in the corner tag (, null hides it) and
+ /// dims what cannot be traded. Call after .
+ ///
+ public void Decorate(string tag, bool dimmed)
+ {
+ if (!_built)
+ return;
+ _tag.Visible = tag != null;
+ if (tag != null)
+ _tagLabel.Text = tag;
+ _icon.Modulate = dimmed ? IconDimmed : Colors.White;
+ _count.Modulate = dimmed ? IconDimmed : Colors.White;
+ }
+
/// Switches the frame to the gold selected state; the panel owns this flag.
public void SetSelected(bool selected)
{
diff --git a/src/ui/InventoryPanelBehaviour.cs b/src/ui/InventoryPanelBehaviour.cs
index b67e58a..c876803 100644
--- a/src/ui/InventoryPanelBehaviour.cs
+++ b/src/ui/InventoryPanelBehaviour.cs
@@ -10,6 +10,7 @@ using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
+using Cthangover.Mods.Items;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.Inventory
{
@@ -978,7 +979,10 @@ namespace Cthangover.Core.UI.Inventory
if (!HasActionPoint())
return;
- if (!item.ItemAction.UseAction(item))
+ var applied = item.ItemAction is ITargetedItemAction targeted
+ ? targeted.UseOn(item, info)
+ : item.ItemAction.UseAction(item);
+ if (!applied)
{
SetMessage(MessageKind.Error, InventoryItemUi.Translate("ui/inventory/applied_failed"));
_targetOpen = false;
diff --git a/tests/FakeActionContext.cs b/tests/FakeActionContext.cs
new file mode 100644
index 0000000..9504fa5
--- /dev/null
+++ b/tests/FakeActionContext.cs
@@ -0,0 +1,33 @@
+namespace Cthangover.Mods.Items.Tests
+{
+ using System.Collections.Generic;
+ using Cthangover.Core.Actions;
+ using Cthangover.Core.Items;
+ using Cthangover.Core.Mods;
+
+ /// Action context for scenario action tests: dialog variables and an inventory, no other services.
+ public class FakeActionContext : IActionContext
+ {
+ /// Dialog variables returned by .
+ public Dictionary Params { get; } = new();
+
+ /// Warnings the action logged.
+ public List Warnings { get; } = new();
+
+ public IQuestService Quests => null;
+ public ICharacterService Character => null;
+ public ILightingService Lighting => null;
+ public ISceneNodeService Scene => null;
+ public IModRegistry ModRegistry => null;
+ public IInventory Inventory { get; set; }
+ public IMapZoneService Map => null;
+
+ public string GetParam(string name) => Params.TryGetValue(name, out var value) ? value : null;
+
+ public void Log(string category, string message)
+ {
+ }
+
+ public void LogWarning(string category, string message) => Warnings.Add(message);
+ }
+}
diff --git a/tests/HealItemActionTests.cs b/tests/HealItemActionTests.cs
new file mode 100644
index 0000000..16371a7
--- /dev/null
+++ b/tests/HealItemActionTests.cs
@@ -0,0 +1,75 @@
+namespace Cthangover.Mods.Items.Tests
+{
+ using Cthangover.Core.Characters;
+ using Cthangover.Core.Items;
+ using Cthangover.Core.Mods.Testing;
+ using Cthangover.Core.Settings;
+ using Cthangover.Core.Utils;
+
+ ///
+ /// action/heal: the amount comes from the item's heal property, health is
+ /// capped at its base value, and an item that does nothing is reported as not used.
+ ///
+ public class HealItemActionTests
+ {
+ private static Item Food(int heal)
+ {
+ var properties = new PropertyData();
+ properties.SetInt(HealItemAction.HealProperty, heal);
+ return new Item { ID = "test/food", Properties = properties };
+ }
+
+ private static CharacterInfoData Hero(int health, int max)
+ {
+ var attributes = new CharacterAttributes();
+ attributes.Health.Value = health;
+ attributes.Health.BaseValue = max;
+ return new CharacterInfoData { ID = "hero", Attributes = attributes };
+ }
+
+ [ModTest("heal/restores_the_amount")]
+ static void RestoresTheAmount(ModTestContext ctx)
+ {
+ var hero = Hero(10, 24);
+ ModAssert.True(new HealItemAction().UseOn(Food(5), hero), "wounded hero is healed");
+ ModAssert.Equal(15, hero.Attributes.Health.Value, "10 + 5");
+ ModAssert.Equal(24, hero.Attributes.Health.BaseValue, "max unchanged");
+ }
+
+ [ModTest("heal/capped_at_max")]
+ static void CappedAtMax(ModTestContext ctx)
+ {
+ var hero = Hero(22, 24);
+ ModAssert.True(new HealItemAction().UseOn(Food(20), hero), "partly wounded hero is healed");
+ ModAssert.Equal(24, hero.Attributes.Health.Value, "never above max");
+ }
+
+ [ModTest("heal/useless_heal_is_not_used")]
+ static void UselessHealIsNotUsed(ModTestContext ctx)
+ {
+ var action = new HealItemAction();
+
+ var full = Hero(24, 24);
+ ModAssert.False(action.UseOn(Food(5), full), "full health");
+ ModAssert.Equal(24, full.Attributes.Health.Value, "full stays full");
+
+ var fallen = Hero(0, 24);
+ ModAssert.False(action.UseOn(Food(5), fallen), "a fallen hero is not revived");
+ ModAssert.Equal(0, fallen.Attributes.Health.Value, "fallen stays fallen");
+
+ var wounded = Hero(10, 24);
+ ModAssert.False(action.UseOn(Food(0), wounded), "no heal amount");
+ ModAssert.False(action.UseOn(new Item { ID = "plain" }, wounded), "no properties");
+ ModAssert.False(action.UseOn(Food(5), null), "no target");
+ ModAssert.False(action.UseOn(Food(5), new CharacterInfoData { ID = "bare" }), "no attributes");
+ ModAssert.False(action.UseAction(Food(5)), "heal needs a target");
+ ModAssert.Equal(10, wounded.Attributes.Health.Value, "untouched");
+ }
+
+ [ModTest("heal/id")]
+ static void Id(ModTestContext ctx)
+ {
+ ModAssert.Equal("action/heal", new HealItemAction().ID, "item definitions reference this id");
+ }
+ }
+}
diff --git a/tests/ItemAddActionTests.cs b/tests/ItemAddActionTests.cs
new file mode 100644
index 0000000..d9131fd
--- /dev/null
+++ b/tests/ItemAddActionTests.cs
@@ -0,0 +1,75 @@
+namespace Cthangover.Mods.Items.Tests
+{
+ using System.Linq;
+ using Cthangover.Core.Items;
+ using Cthangover.Core.Mods.Resolvers;
+ using Cthangover.Core.Mods.Testing;
+
+ /// action item.add from a scenario: a count defaults to one, a bad one adds nothing.
+ public class ItemAddActionTests
+ {
+ private static FakeActionContext Context(string id, string count)
+ {
+ var ctx = new FakeActionContext { Inventory = new Inventory() };
+ if (id != null)
+ ctx.Params["id"] = id;
+ if (count != null)
+ ctx.Params["count"] = count;
+ return ctx;
+ }
+
+ [ModTest("item_add/adds_count")]
+ static void AddsCount(ModTestContext test)
+ {
+ var id = Entities.Collect().Keys.First();
+ var ctx = Context(id, "3");
+ var before = ctx.Inventory.CheckCount(id);
+
+ new ItemAddAction().Run(ctx);
+
+ ModAssert.Equal(before + 3, ctx.Inventory.CheckCount(id), "three added");
+ ModAssert.Equal(0, ctx.Warnings.Count, "no warnings");
+ }
+
+ [ModTest("item_add/count_defaults_to_one")]
+ static void CountDefaultsToOne(ModTestContext test)
+ {
+ var id = Entities.Collect().Keys.First();
+ var ctx = Context(id, null);
+ var before = ctx.Inventory.CheckCount(id);
+
+ new ItemAddAction().Run(ctx);
+
+ ModAssert.Equal(before + 1, ctx.Inventory.CheckCount(id), "one added");
+ }
+
+ [ModTest("item_add/bad_arguments_add_nothing")]
+ static void BadArgumentsAddNothing(ModTestContext test)
+ {
+ var id = Entities.Collect().Keys.First();
+ foreach (var count in new[] { "0", "-2", "many" })
+ {
+ var ctx = Context(id, count);
+ var before = ctx.Inventory.CheckCount(id);
+ new ItemAddAction().Run(ctx);
+ ModAssert.Equal(before, ctx.Inventory.CheckCount(id), "count=" + count);
+ ModAssert.Equal(1, ctx.Warnings.Count, "warned about count=" + count);
+ }
+
+ var noId = Context(null, "1");
+ new ItemAddAction().Run(noId);
+ ModAssert.Equal(1, noId.Warnings.Count, "warned about the missing id");
+
+ var noInventory = new FakeActionContext();
+ noInventory.Params["id"] = id;
+ new ItemAddAction().Run(noInventory);
+ ModAssert.Equal(1, noInventory.Warnings.Count, "warned about the missing inventory");
+ }
+
+ [ModTest("item_add/name")]
+ static void Name(ModTestContext test)
+ {
+ ModAssert.Equal("item.add", new ItemAddAction().Name, "DSL name");
+ }
+ }
+}