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