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