diff --git a/locale/schema_en.properties b/locale/schema_en.properties index d83f1f1..56241c6 100644 --- a/locale/schema_en.properties +++ b/locale/schema_en.properties @@ -12,3 +12,6 @@ items/schema/field/item_type = Item types items/schema/field/item_type_desc = Comma separated: Resource, Food, Used, TargetUsed, Quest, Recipe, CantDrop items/schema/field/item_action = Item action items/schema/field/item_action_desc = action/heal, action/equip, action/stat_up… or null + +items/schema/remove = Remove item +items/schema/remove_desc = Removes a positive stack. Gate the delivery with item('id') >= count first. diff --git a/locale/schema_ru.properties b/locale/schema_ru.properties index f98b6c8..e207135 100644 --- a/locale/schema_ru.properties +++ b/locale/schema_ru.properties @@ -12,3 +12,6 @@ items/schema/field/item_type = Типы предмета items/schema/field/item_type_desc = Через запятую: Resource, Food, Used, TargetUsed, Quest, Recipe, CantDrop items/schema/field/item_action = Действие предмета items/schema/field/item_action_desc = action/heal, action/equip, action/stat_up… или null + +items/schema/remove = Забрать предмет +items/schema/remove_desc = Убирает указанное число предметов. Сначала проверьте количество условием item('id') >= число. diff --git a/schemas/items_base.json b/schemas/items_base.json index 4fd4b0e..2d1c4c3 100644 --- a/schemas/items_base.json +++ b/schemas/items_base.json @@ -20,6 +20,27 @@ "Name": "items/schema/field/count" } ] + }, + { + "Id": "item.remove", + "Name": "items/schema/remove", + "Description": "items/schema/remove_desc", + "Category": "items", + "Params": [ + { + "Key": "id", + "Type": "ref", + "Kind": "item", + "Required": true, + "Name": "items/schema/field/item" + }, + { + "Key": "count", + "Type": "int", + "Default": "1", + "Name": "items/schema/field/count" + } + ] } ], "Kinds": [ diff --git a/src/ItemCountCondition.cs b/src/ItemCountCondition.cs new file mode 100644 index 0000000..85fab4e --- /dev/null +++ b/src/ItemCountCondition.cs @@ -0,0 +1,25 @@ +namespace Cthangover.Mods.Items +{ + using Cthangover.Core.Scenes; + using Cthangover.Core.Settings; + + /// Reads the current bag on evaluation, so saves and new games never leave a cached inventory behind. + public class ItemCountCondition : ConditionNode + { + private readonly string id; + private readonly string op; + private readonly int count; + + /// Compares the owned count of an item with a whole-number threshold. + public ItemCountCondition(string id, string op, int count) + { + this.id = id; + this.op = op; + this.count = count; + } + + /// + public override bool Evaluate(EvaluationContext ctx) + => ConditionCompare.Compare(op, GameData.Instance?.Runtime?.Inventory?.CheckCount(id) ?? 0, count); + } +} diff --git a/src/ItemCountParser.cs b/src/ItemCountParser.cs new file mode 100644 index 0000000..c3bc923 --- /dev/null +++ b/src/ItemCountParser.cs @@ -0,0 +1,36 @@ +namespace Cthangover.Mods.Items +{ + using System; + using Cthangover.Core.Scenes; + using Cthangover.Core.Scenes.Parsing; + + /// Adds item('id') OP count to the shared condition language, usable by quests, choices and map pages alike. + public class ItemCountParser : ILeafParser + { + /// + public bool CanParse(TokenPeeker peeker) => !peeker.AtEnd + && peeker.Peek.Type == ConditionParser.TokenType.Ident && peeker.Peek.Value == "item" + && peeker.PeekAhead(1).Type == ConditionParser.TokenType.LParen; + + /// + public ICondition Parse(TokenPeeker peeker, Func parseSubExpr) + { + peeker.Advance(); + peeker.Expect(ConditionParser.TokenType.LParen, "expected '(' after item"); + if (!peeker.Check(ConditionParser.TokenType.String) && !peeker.Check(ConditionParser.TokenType.Ident)) + throw new FormatException("item() expects an item id"); + var id = peeker.Advance().Value; + peeker.Expect(ConditionParser.TokenType.RParen, "expected ')' after item id"); + var op = peeker.Peek.Type switch + { + ConditionParser.TokenType.Eq => "==", ConditionParser.TokenType.Neq => "!=", + ConditionParser.TokenType.Ge => ">=", ConditionParser.TokenType.Le => "<=", + ConditionParser.TokenType.Gt => ">", ConditionParser.TokenType.Lt => "<", + _ => throw new FormatException("item() expects a comparison") + }; + peeker.Advance(); + var count = peeker.Expect(ConditionParser.TokenType.Number, "item() expects a whole-number count"); + return new ItemCountCondition(id, op, int.Parse(count.Value)); + } + } +} diff --git a/src/ItemRemoveAction.cs b/src/ItemRemoveAction.cs new file mode 100644 index 0000000..94a1e73 --- /dev/null +++ b/src/ItemRemoveAction.cs @@ -0,0 +1,26 @@ +namespace Cthangover.Mods.Items +{ + using Cthangover.Core.Actions; + + /// Consumes a positive stack from a scenario. Gate deliveries with item('id') >= count before running it. + public class ItemRemoveAction : IScenarioAction + { + /// Scenario id. + public string Name => "item.remove"; + + /// Refuses an invalid or insufficient stack without changing inventory. + public void Run(IActionContext ctx) + { + var id = ctx.GetParam("id")?.Trim(); + var text = ctx.GetParam("count"); + var count = 1; + if (string.IsNullOrWhiteSpace(id) || (!string.IsNullOrWhiteSpace(text) && !int.TryParse(text, out count)) || count <= 0) + { + ctx.LogWarning("INVENTORY", "item.remove: expected an item id and a positive count"); + return; + } + if (ctx.Inventory?.Remove(id, count) != true) + ctx.LogWarning("INVENTORY", $"item.remove: insufficient '{id}' (need {count})"); + } + } +} diff --git a/src/ItemsModInitializer.cs b/src/ItemsModInitializer.cs index c57d077..f106328 100644 --- a/src/ItemsModInitializer.cs +++ b/src/ItemsModInitializer.cs @@ -1,4 +1,7 @@ using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Events; +using Cthangover.Core.Scenes; +using Cthangover.Core.Scenes.Parsing; using Cthangover.Core.Items; using Cthangover.Core.Mods; using Cthangover.Core.Settings; @@ -23,9 +26,15 @@ namespace Cthangover.Mods.Items /// public class ItemsModInitializer : IModInitializer { + private static bool conditionsRegistered; /// No per-mod work is needed on load; discovery is reflection-based. public void OnModLoaded(string modId) { + if (conditionsRegistered) + return; + conditionsRegistered = true; + LeafParserRegistry.RegisterFirst(new ItemCountParser()); + ModEventBus.Subscribe(ConditionStateMonitor.Notify); } /// Installs the RuntimeData factory hooks and binds the item-action bridge (both idempotent). diff --git a/tests/ItemCountConditionTests.cs b/tests/ItemCountConditionTests.cs new file mode 100644 index 0000000..18819ce --- /dev/null +++ b/tests/ItemCountConditionTests.cs @@ -0,0 +1,33 @@ +namespace Cthangover.Mods.Items.Tests +{ + using Cthangover.Core.Items; + using Cthangover.Core.Mods.Resolvers; + using Cthangover.Core.Mods.Testing; + using Cthangover.Core.Scenes; + using Cthangover.Core.Settings; + using System.Linq; + + /// Conditions read current inventory state and compose with the scenario language's existing operators. + public class ItemCountConditionTests + { + [ModTest("items/count_condition_follows_bag_changes")] + static void CountFollowsBag(ModTestContext ctx) + { + var bag = GameData.Instance.Runtime.Inventory; + var before = bag.Items; + bag.Items = new(); + try + { + var id = Entities.Collect().Keys.First(); + ModAssert.True(ScenarioCondition.Evaluate($"item('{id}') == 0"), "empty bag"); + bag.Add(id, 3); + ModAssert.True(ScenarioCondition.Evaluate($"item('{id}') >= 3 && item('{id}') < 4"), "count range"); + ModAssert.False(ScenarioCondition.Evaluate($"item('{id}') > 3"), "upper bound"); + bag.Remove(id, 2); + ModAssert.True(ScenarioCondition.Evaluate($"item('{id}') == 1"), "updated bag"); + ModAssert.True(ScenarioCondition.Evaluate("item('missing/item') == 0"), "unknown id counts zero"); + } + finally { bag.Items = before; } + } + } +}