From 0482e06a56f737885ca48248c9ccce12d7a43af6 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Tue, 20 Jan 2026 22:04:33 +0100 Subject: [PATCH 01/20] feat: add nbt to jmespath evaluation --- .../sfm/common/util/NbtJmesPathEvaluator.java | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java diff --git a/src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java b/src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java new file mode 100644 index 000000000..75331e8ed --- /dev/null +++ b/src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java @@ -0,0 +1,186 @@ +package ca.teamdman.sfm.common.util; + +import com.google.gson.*; +import io.burt.jmespath.Expression; +import io.burt.jmespath.JmesPath; +import io.burt.jmespath.gson.GsonRuntime; +import net.minecraft.nbt.*; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.fluids.FluidStack; +import org.jetbrains.annotations.Nullable; + +/** + * Utility class for evaluating JMESPath expressions against Minecraft NBT data. + * Converts NBT to JSON, then applies JMESPath queries. + */ +public class NbtJmesPathEvaluator { + private static final JmesPath JMESPATH_RUNTIME = new GsonRuntime(); + private final Expression expression; + + public NbtJmesPathEvaluator(String jmesPathExpression) { + this.expression = JMESPATH_RUNTIME.compile(jmesPathExpression); + } + + /** + * Compiles a JMESPath expression, throwing an exception if it's invalid. + * Use this method to validate expressions at parse time. + * + * @param jmesPathExpression The JMESPath expression to compile + * @return A compiled NbtJmesPathEvaluator + * @throws io.burt.jmespath.parser.ParseException if the expression is invalid + */ + public static NbtJmesPathEvaluator compile(String jmesPathExpression) { + return new NbtJmesPathEvaluator(jmesPathExpression); + } + + /** + * Evaluates the JMESPath expression against an ItemStack's NBT. + * + * @param stack The ItemStack to query + * @return true if the result is truthy (non-null, non-empty, non-false, non-zero) + */ + public boolean matchesItemStack(ItemStack stack) { + CompoundTag tag = stack.getTag(); + return matchesNbt(tag); + } + + /** + * Evaluates the JMESPath expression against a FluidStack's NBT. + * + * @param stack The FluidStack to query + * @return true if the result is truthy (non-null, non-empty, non-false, non-zero) + */ + public boolean matchesFluidStack(FluidStack stack) { + CompoundTag tag = stack.getTag(); + return matchesNbt(tag); + } + + /** + * Evaluates the JMESPath expression against a CompoundTag. + * + * @param tag The NBT tag to query (may be null) + * @return true if the result is truthy + */ + public boolean matchesNbt(@Nullable CompoundTag tag) { + JsonElement json = nbtToJson(tag); + JsonElement result = expression.search(json); + return isTruthy(result); + } + + /** + * Converts a Minecraft NBT Tag to a Gson JsonElement. + * + * @param tag The NBT tag (may be null) + * @return The corresponding JsonElement + */ + public static JsonElement nbtToJson(@Nullable Tag tag) { + if (tag == null) { + return JsonNull.INSTANCE; + } + + return switch (tag.getId()) { + case Tag.TAG_BYTE -> new JsonPrimitive(((ByteTag) tag).getAsByte()); + case Tag.TAG_SHORT -> new JsonPrimitive(((ShortTag) tag).getAsShort()); + case Tag.TAG_INT -> new JsonPrimitive(((IntTag) tag).getAsInt()); + case Tag.TAG_LONG -> new JsonPrimitive(((LongTag) tag).getAsLong()); + case Tag.TAG_FLOAT -> new JsonPrimitive(((FloatTag) tag).getAsFloat()); + case Tag.TAG_DOUBLE -> new JsonPrimitive(((DoubleTag) tag).getAsDouble()); + case Tag.TAG_STRING -> new JsonPrimitive(tag.getAsString()); + case Tag.TAG_BYTE_ARRAY -> byteArrayToJson((ByteArrayTag) tag); + case Tag.TAG_INT_ARRAY -> intArrayToJson((IntArrayTag) tag); + case Tag.TAG_LONG_ARRAY -> longArrayToJson((LongArrayTag) tag); + case Tag.TAG_LIST -> listToJson((ListTag) tag); + case Tag.TAG_COMPOUND -> compoundToJson((CompoundTag) tag); + default -> JsonNull.INSTANCE; + }; + } + + private static JsonArray byteArrayToJson(ByteArrayTag tag) { + JsonArray array = new JsonArray(); + for (byte b : tag.getAsByteArray()) { + array.add(b); + } + return array; + } + + private static JsonArray intArrayToJson(IntArrayTag tag) { + JsonArray array = new JsonArray(); + for (int i : tag.getAsIntArray()) { + array.add(i); + } + return array; + } + + private static JsonArray longArrayToJson(LongArrayTag tag) { + JsonArray array = new JsonArray(); + for (long l : tag.getAsLongArray()) { + array.add(l); + } + return array; + } + + private static JsonArray listToJson(ListTag tag) { + JsonArray array = new JsonArray(); + for (Tag element : tag) { + array.add(nbtToJson(element)); + } + return array; + } + + private static JsonObject compoundToJson(CompoundTag tag) { + JsonObject object = new JsonObject(); + for (String key : tag.getAllKeys()) { + object.add(key, nbtToJson(tag.get(key))); + } + return object; + } + + /** + * Determines if a JMESPath result is truthy. + * A result is truthy unless it is: + * - null + * - empty array + * - empty object + * - empty string + * - boolean false + * - number 0 + * + * @param result The result from a JMESPath expression + * @return true if the result is truthy + */ + public static boolean isTruthy(@Nullable JsonElement result) { + if (result == null || result.isJsonNull()) { + return false; + } + + if (result.isJsonPrimitive()) { + JsonPrimitive primitive = result.getAsJsonPrimitive(); + if (primitive.isBoolean()) { + return primitive.getAsBoolean(); + } + if (primitive.isNumber()) { + return primitive.getAsDouble() != 0; + } + if (primitive.isString()) { + return !primitive.getAsString().isEmpty(); + } + } + + if (result.isJsonArray()) { + return !result.getAsJsonArray().isEmpty(); + } + + if (result.isJsonObject()) { + return !result.getAsJsonObject().isEmpty(); + } + + return true; + } + + /** + * Gets the expression string for display/debugging purposes. + */ + public String getExpressionString() { + return expression.toString(); + } +} From f4d9c933cbfaae592ea440bf24fcae198bdc524c Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Tue, 20 Jan 2026 22:04:33 +0100 Subject: [PATCH 02/20] build: add jmespath dependencies and enable jarjar bundling --- build.gradle | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/build.gradle b/build.gradle index 354a34ff5..afaa6c7b6 100644 --- a/build.gradle +++ b/build.gradle @@ -303,6 +303,22 @@ dependencies { // http://www.gradle.org/docs/current/userguide/dependency_management.html antlr 'org.antlr:antlr4:4.9.1' + + // JMESPath for NBT filtering - bundled via JarJar for distribution + jarJar('io.burt:jmespath-core:0.6.0') { + version { + strictly '[0.6.0,0.7.0)' + } + } + jarJar('io.burt:jmespath-gson:0.6.0') { + version { + strictly '[0.6.0,0.7.0)' + } + } + // Add to minecraftLibrary for development runtime (runClient, etc.) + minecraftLibrary 'io.burt:jmespath-core:0.6.0' + minecraftLibrary 'io.burt:jmespath-gson:0.6.0' + testImplementation 'com.github.javaparser:javaparser-symbol-solver-core:3.26.4' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' @@ -503,6 +519,9 @@ jar.finalizedBy('reobfJar') // However if you are in a multi-project build, dev time needs unobfed jar files, so you can delay the obfuscation until publishing by doing // publish.dependsOn('reobfJar') +// Enable jarJar for bundling JMESPath library +jarJar.enable() + publishing { publications { mavenJava(MavenPublication) { From 15b9be8e4852d66b5451bf4fe9bc4e855a0795c8 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Tue, 20 Jan 2026 22:04:33 +0100 Subject: [PATCH 03/20] feat: extend sfml grammar to support nbt filtering --- src/main/antlr/sfml/SFML.g4 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/antlr/sfml/SFML.g4 b/src/main/antlr/sfml/SFML.g4 index 3c2e70f78..bb3290759 100644 --- a/src/main/antlr/sfml/SFML.g4 +++ b/src/main/antlr/sfml/SFML.g4 @@ -75,6 +75,7 @@ withClause : LPAREN withClause RPAREN # WithParen | withClause AND withClause # WithConjunction | withClause OR withClause # WithDisjunction | (TAG HASHTAG?|HASHTAG) tagMatcher # WithTag + | NBT string # WithNbt ; tagMatcher : identifier COLON identifier (SLASH identifier)* @@ -211,6 +212,7 @@ IN : I N ; WITHOUT : W I T H O U T; WITH : W I T H ; TAG : T A G ; +NBT : N B T ; HASHTAG : '#' ; // ROUND ROBIN From 4e2799472452f01a4b760d0a8117928ee7d25358 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Tue, 20 Jan 2026 22:04:34 +0100 Subject: [PATCH 04/20] feat: implement withnbt ast node and integrate into astbuilder --- .../java/ca/teamdman/sfml/ast/ASTBuilder.java | 9 +++ .../java/ca/teamdman/sfml/ast/WithNbt.java | 59 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/main/java/ca/teamdman/sfml/ast/WithNbt.java diff --git a/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java b/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java index 7d0c4d10f..bde05bda7 100644 --- a/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java +++ b/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java @@ -689,6 +689,15 @@ public WithDisjunction visitWithDisjunction(SFMLParser.WithDisjunctionContext ct return rtn; } + @Override + public WithNbt visitWithNbt(SFMLParser.WithNbtContext ctx) { + + String expression = visitString(ctx.string()).value(); + WithNbt rtn = WithNbt.create(expression); + trackNode(rtn, ctx); + return rtn; + } + @Override public TagMatcher visitTagMatcher(SFMLParser.TagMatcherContext ctx) { diff --git a/src/main/java/ca/teamdman/sfml/ast/WithNbt.java b/src/main/java/ca/teamdman/sfml/ast/WithNbt.java new file mode 100644 index 000000000..ab9ae4950 --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/WithNbt.java @@ -0,0 +1,59 @@ +package ca.teamdman.sfml.ast; + +import ca.teamdman.sfm.common.resourcetype.ResourceType; +import ca.teamdman.sfm.common.util.NbtJmesPathEvaluator; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.fluids.FluidStack; + +/** + * AST node for NBT filtering using JMESPath expressions. + * Example usage: INPUT WITH NBT "Damage > `10`" FROM a + */ +public record WithNbt( + String jmesPathExpression, + NbtJmesPathEvaluator evaluator +) implements ASTNode, WithClause, ToStringPretty { + + /** + * Creates a WithNbt node, compiling the JMESPath expression for validation. + * + * @param jmesPathExpression The JMESPath expression string + * @return A new WithNbt node + * @throws io.burt.jmespath.parser.ParseException if the expression is invalid + */ + public static WithNbt create(String jmesPathExpression) { + NbtJmesPathEvaluator evaluator = NbtJmesPathEvaluator.compile(jmesPathExpression); + return new WithNbt(jmesPathExpression, evaluator); + } + + @Override + public boolean matchesStack( + ResourceType resourceType, + STACK stack + ) { + CompoundTag tag = getNbtFromStack(stack); + return evaluator.matchesNbt(tag); + } + + /** + * Extracts the NBT CompoundTag from a stack. + * Supports ItemStack and FluidStack. + * + * @param stack The stack to extract NBT from + * @return The CompoundTag, or null if the stack type is not supported or has no NBT + */ + private static CompoundTag getNbtFromStack(Object stack) { + if (stack instanceof ItemStack itemStack) { + return itemStack.getTag(); + } else if (stack instanceof FluidStack fluidStack) { + return fluidStack.getTag(); + } + return null; + } + + @Override + public String toString() { + return "NBT \"" + jmesPathExpression.replace("\"", "\\\"") + "\""; + } +} From 1091b47722828a1086e7ca79afe110fbf2f82385 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Tue, 20 Jan 2026 22:08:49 +0100 Subject: [PATCH 05/20] feat(sfml): introduce NBT filtering syntax to SFML grammar --- vscodeextension/super-factory-manager-language/syntaxes/SFML.g4 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vscodeextension/super-factory-manager-language/syntaxes/SFML.g4 b/vscodeextension/super-factory-manager-language/syntaxes/SFML.g4 index ee6f13f95..38b5de0ba 100644 --- a/vscodeextension/super-factory-manager-language/syntaxes/SFML.g4 +++ b/vscodeextension/super-factory-manager-language/syntaxes/SFML.g4 @@ -76,6 +76,7 @@ withClause : LPAREN withClause RPAREN # WithParen | withClause AND withClause # WithConjunction | withClause OR withClause # WithDisjunction | (TAG HASHTAG?|HASHTAG) tagMatcher # WithTag + | NBT string # WithNbt ; tagMatcher : identifier COLON identifier (SLASH identifier)* @@ -212,6 +213,7 @@ IN : I N ; WITHOUT : W I T H O U T; WITH : W I T H ; TAG : T A G ; +NBT : N B T ; HASHTAG : '#' ; // ROUND ROBIN From 2c2e26112f446a5ab482ffa33b73a80b5eb653c0 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Tue, 20 Jan 2026 22:08:49 +0100 Subject: [PATCH 06/20] test: add nbt filtering tests --- .../NbtFilterCombinedWithTagGameTest.java | 62 +++ .../NbtFilterDamagedItemsGameTest.java | 60 +++ .../NbtFilterEnchantedItemsGameTest.java | 59 +++ .../NbtFilterWithoutEnchantmentsGameTest.java | 59 +++ .../tests/nbt_filtering/package-info.java | 4 + .../sfm/common/util/NbtJmesPathEvaluator.java | 4 +- .../teamdman/sfml/SFMLNbtFilteringTests.java | 456 ++++++++++++++++++ 7 files changed, 702 insertions(+), 2 deletions(-) create mode 100644 src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterCombinedWithTagGameTest.java create mode 100644 src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterDamagedItemsGameTest.java create mode 100644 src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterEnchantedItemsGameTest.java create mode 100644 src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterWithoutEnchantmentsGameTest.java create mode 100644 src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/package-info.java create mode 100644 src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterCombinedWithTagGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterCombinedWithTagGameTest.java new file mode 100644 index 000000000..a30ea8edd --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterCombinedWithTagGameTest.java @@ -0,0 +1,62 @@ +package ca.teamdman.sfm.gametest.tests.nbt_filtering; + +import ca.teamdman.sfm.gametest.LeftRightManagerTest; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.enchantment.Enchantments; + +import java.util.Arrays; + +/** + * Tests combining NBT filtering with TAG filtering. + * Move only damaged swords (not other damaged items). + */ +@SFMGameTest +public class NbtFilterCombinedWithTagGameTest extends SFMGameTestDefinition { + @Override + public String template() { + return "3x2x1"; + } + + @Override + public void run(SFMGameTestHelper helper) { + var test = new LeftRightManagerTest(helper); + + // Create a damaged sword + ItemStack damagedSword = new ItemStack(Items.DIAMOND_SWORD); + damagedSword.setDamageValue(50); + + // Create a damaged pickaxe + ItemStack damagedPickaxe = new ItemStack(Items.DIAMOND_PICKAXE); + damagedPickaxe.setDamageValue(50); + + test.setProgram(""" + EVERY 20 TICKS DO + -- Only move damaged items that are also swords (have the sword tag) + INPUT WITH NBT "Damage > `0`" AND #minecraft:swords FROM left + OUTPUT TO right + END + """); + + // Put both items in the left chest + test.preContents("left", Arrays.asList( + damagedSword, + damagedPickaxe + )); + + // Only the damaged sword should move (matches both NBT and TAG filters) + // The damaged pickaxe stays because it doesn't have the sword tag + test.postContents("left", Arrays.asList( + ItemStack.EMPTY, + damagedPickaxe + )); + test.postContents("right", Arrays.asList( + damagedSword + )); + + test.run(); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterDamagedItemsGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterDamagedItemsGameTest.java new file mode 100644 index 000000000..79ff4559f --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterDamagedItemsGameTest.java @@ -0,0 +1,60 @@ +package ca.teamdman.sfm.gametest.tests.nbt_filtering; + +import ca.teamdman.sfm.gametest.LeftRightManagerTest; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +import java.util.Arrays; +import java.util.Collections; + +/** + * Tests that NBT filtering can move only damaged items (Damage > 0). + */ +@SFMGameTest +public class NbtFilterDamagedItemsGameTest extends SFMGameTestDefinition { + @Override + public String template() { + return "3x2x1"; + } + + @Override + public void run(SFMGameTestHelper helper) { + var test = new LeftRightManagerTest(helper); + + // Create a damaged sword (has Damage NBT) + ItemStack damagedSword = new ItemStack(Items.DIAMOND_SWORD); + damagedSword.setDamageValue(50); + + // Create an undamaged sword (Damage = 0, but still has the tag) + ItemStack undamagedSword = new ItemStack(Items.DIAMOND_SWORD); + + test.setProgram(""" + EVERY 20 TICKS DO + -- Only move items with Damage > 0 + INPUT WITH NBT "Damage > `0`" FROM left + OUTPUT TO right + END + """); + + // Put both swords in the left chest + test.preContents("left", Arrays.asList( + damagedSword, + undamagedSword + )); + + // Only the damaged sword should move to the right + // The undamaged sword stays in the left + test.postContents("left", Arrays.asList( + ItemStack.EMPTY, + undamagedSword + )); + test.postContents("right", Arrays.asList( + damagedSword + )); + + test.run(); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterEnchantedItemsGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterEnchantedItemsGameTest.java new file mode 100644 index 000000000..83abaa3b8 --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterEnchantedItemsGameTest.java @@ -0,0 +1,59 @@ +package ca.teamdman.sfm.gametest.tests.nbt_filtering; + +import ca.teamdman.sfm.gametest.LeftRightManagerTest; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.enchantment.Enchantments; + +import java.util.Arrays; + +/** + * Tests that NBT filtering can move only enchanted items. + */ +@SFMGameTest +public class NbtFilterEnchantedItemsGameTest extends SFMGameTestDefinition { + @Override + public String template() { + return "3x2x1"; + } + + @Override + public void run(SFMGameTestHelper helper) { + var test = new LeftRightManagerTest(helper); + + // Create an enchanted sword + ItemStack enchantedSword = new ItemStack(Items.DIAMOND_SWORD); + enchantedSword.enchant(Enchantments.SHARPNESS, 5); + + // Create a plain sword (no enchantments) + ItemStack plainSword = new ItemStack(Items.DIAMOND_SWORD); + + test.setProgram(""" + EVERY 20 TICKS DO + -- Only move items with enchantments (Enchantments array is non-empty) + INPUT WITH NBT "Enchantments[0]" FROM left + OUTPUT TO right + END + """); + + // Put both swords in the left chest + test.preContents("left", Arrays.asList( + enchantedSword, + plainSword + )); + + // Only the enchanted sword should move to the right + test.postContents("left", Arrays.asList( + ItemStack.EMPTY, + plainSword + )); + test.postContents("right", Arrays.asList( + enchantedSword + )); + + test.run(); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterWithoutEnchantmentsGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterWithoutEnchantmentsGameTest.java new file mode 100644 index 000000000..283b1e57a --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterWithoutEnchantmentsGameTest.java @@ -0,0 +1,59 @@ +package ca.teamdman.sfm.gametest.tests.nbt_filtering; + +import ca.teamdman.sfm.gametest.LeftRightManagerTest; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.enchantment.Enchantments; + +import java.util.Arrays; + +/** + * Tests that WITHOUT NBT filtering can move only non-enchanted items. + */ +@SFMGameTest +public class NbtFilterWithoutEnchantmentsGameTest extends SFMGameTestDefinition { + @Override + public String template() { + return "3x2x1"; + } + + @Override + public void run(SFMGameTestHelper helper) { + var test = new LeftRightManagerTest(helper); + + // Create an enchanted sword + ItemStack enchantedSword = new ItemStack(Items.DIAMOND_SWORD); + enchantedSword.enchant(Enchantments.SHARPNESS, 5); + + // Create a plain sword (no enchantments) + ItemStack plainSword = new ItemStack(Items.DIAMOND_SWORD); + + test.setProgram(""" + EVERY 20 TICKS DO + -- Only move items WITHOUT enchantments + INPUT WITHOUT NBT "Enchantments[0]" FROM left + OUTPUT TO right + END + """); + + // Put both swords in the left chest + test.preContents("left", Arrays.asList( + enchantedSword, + plainSword + )); + + // Only the plain sword should move to the right + test.postContents("left", Arrays.asList( + enchantedSword, + ItemStack.EMPTY + )); + test.postContents("right", Arrays.asList( + plainSword + )); + + test.run(); + } +} diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/package-info.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/package-info.java new file mode 100644 index 000000000..430b81756 --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/package-info.java @@ -0,0 +1,4 @@ +/** + * Game tests for NBT filtering feature using JMESPath expressions. + */ +package ca.teamdman.sfm.gametest.tests.nbt_filtering; diff --git a/src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java b/src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java index 75331e8ed..88a1b5034 100644 --- a/src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java +++ b/src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java @@ -167,11 +167,11 @@ public static boolean isTruthy(@Nullable JsonElement result) { } if (result.isJsonArray()) { - return !result.getAsJsonArray().isEmpty(); + return result.getAsJsonArray().size() > 0; } if (result.isJsonObject()) { - return !result.getAsJsonObject().isEmpty(); + return result.getAsJsonObject().size() > 0; } return true; diff --git a/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java b/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java new file mode 100644 index 000000000..162a56887 --- /dev/null +++ b/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java @@ -0,0 +1,456 @@ +package ca.teamdman.sfml; + +import ca.teamdman.sfm.common.util.NbtJmesPathEvaluator; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonPrimitive; +import io.burt.jmespath.parser.ParseException; +import net.minecraft.nbt.*; +import org.junit.jupiter.api.Test; + +import static ca.teamdman.sfml.SFMLTestHelpers.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the NBT JMESPath filtering feature. + */ +public class SFMLNbtFilteringTests { + + // ==================== Grammar Parsing Tests ==================== + + @Test + public void nbtFilterBasicSyntax() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT "Damage" FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtFilterWithDamageQuery() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT "Damage > `10`" FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtFilterWithEnchantmentsQuery() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT "Enchantments[0]" FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtFilterComplexEnchantmentQuery() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT "Enchantments[?id == 'minecraft:sharpness' && lvl > `3`]" FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtFilterWithoutNbt() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITHOUT NBT "display.Name" FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtFilterCombinedWithTag() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT "Damage" AND TAG minecraft:swords FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtFilterWithOr() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT "Damage" OR TAG minecraft:tools FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtFilterNegated() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NOT NBT "Damage" FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtFilterParenthesized() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH (NBT "Damage" AND TAG minecraft:swords) FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtFilterInResourceLimit() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT 64 diamond_sword WITH NBT "Damage < `100`" FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtFilterInBooleanHas() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT FROM a + IF a HAS > 0 diamond_sword WITH NBT "Enchantments[0]" THEN + OUTPUT TO b + END + END + """); + } + + @Test + public void nbtFilterInvalidExpressionSyntax() { + // Invalid JMESPath syntax should cause a compile error + assertCompileErrorsPresent(""" + EVERY 20 TICKS DO + INPUT WITH NBT "[?invalid syntax here" FROM a + OUTPUT TO b + END + """); + } + + // ==================== NBT to JSON Conversion Tests ==================== + + @Test + public void nbtToJsonNull() { + JsonElement result = NbtJmesPathEvaluator.nbtToJson(null); + assertTrue(result.isJsonNull()); + } + + @Test + public void nbtToJsonByte() { + ByteTag tag = ByteTag.valueOf((byte) 42); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonPrimitive()); + assertEquals(42, result.getAsInt()); + } + + @Test + public void nbtToJsonShort() { + ShortTag tag = ShortTag.valueOf((short) 1000); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonPrimitive()); + assertEquals(1000, result.getAsInt()); + } + + @Test + public void nbtToJsonInt() { + IntTag tag = IntTag.valueOf(100000); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonPrimitive()); + assertEquals(100000, result.getAsInt()); + } + + @Test + public void nbtToJsonLong() { + LongTag tag = LongTag.valueOf(10000000000L); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonPrimitive()); + assertEquals(10000000000L, result.getAsLong()); + } + + @Test + public void nbtToJsonFloat() { + FloatTag tag = FloatTag.valueOf(3.14f); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonPrimitive()); + assertEquals(3.14f, result.getAsFloat(), 0.001f); + } + + @Test + public void nbtToJsonDouble() { + DoubleTag tag = DoubleTag.valueOf(3.14159265359); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonPrimitive()); + assertEquals(3.14159265359, result.getAsDouble(), 0.0000001); + } + + @Test + public void nbtToJsonString() { + StringTag tag = StringTag.valueOf("hello world"); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonPrimitive()); + assertEquals("hello world", result.getAsString()); + } + + @Test + public void nbtToJsonByteArray() { + ByteArrayTag tag = new ByteArrayTag(new byte[]{1, 2, 3}); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonArray()); + assertEquals(3, result.getAsJsonArray().size()); + assertEquals(1, result.getAsJsonArray().get(0).getAsInt()); + assertEquals(2, result.getAsJsonArray().get(1).getAsInt()); + assertEquals(3, result.getAsJsonArray().get(2).getAsInt()); + } + + @Test + public void nbtToJsonIntArray() { + IntArrayTag tag = new IntArrayTag(new int[]{100, 200, 300}); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonArray()); + assertEquals(3, result.getAsJsonArray().size()); + assertEquals(100, result.getAsJsonArray().get(0).getAsInt()); + assertEquals(200, result.getAsJsonArray().get(1).getAsInt()); + assertEquals(300, result.getAsJsonArray().get(2).getAsInt()); + } + + @Test + public void nbtToJsonLongArray() { + LongArrayTag tag = new LongArrayTag(new long[]{1000000000L, 2000000000L}); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonArray()); + assertEquals(2, result.getAsJsonArray().size()); + assertEquals(1000000000L, result.getAsJsonArray().get(0).getAsLong()); + assertEquals(2000000000L, result.getAsJsonArray().get(1).getAsLong()); + } + + @Test + public void nbtToJsonList() { + ListTag tag = new ListTag(); + tag.add(StringTag.valueOf("a")); + tag.add(StringTag.valueOf("b")); + tag.add(StringTag.valueOf("c")); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonArray()); + assertEquals(3, result.getAsJsonArray().size()); + assertEquals("a", result.getAsJsonArray().get(0).getAsString()); + assertEquals("b", result.getAsJsonArray().get(1).getAsString()); + assertEquals("c", result.getAsJsonArray().get(2).getAsString()); + } + + @Test + public void nbtToJsonCompound() { + CompoundTag tag = new CompoundTag(); + tag.putString("name", "test"); + tag.putInt("value", 42); + JsonElement result = NbtJmesPathEvaluator.nbtToJson(tag); + assertTrue(result.isJsonObject()); + assertEquals("test", result.getAsJsonObject().get("name").getAsString()); + assertEquals(42, result.getAsJsonObject().get("value").getAsInt()); + } + + @Test + public void nbtToJsonNestedCompound() { + CompoundTag inner = new CompoundTag(); + inner.putString("innerKey", "innerValue"); + + CompoundTag outer = new CompoundTag(); + outer.put("nested", inner); + + JsonElement result = NbtJmesPathEvaluator.nbtToJson(outer); + assertTrue(result.isJsonObject()); + assertTrue(result.getAsJsonObject().has("nested")); + assertTrue(result.getAsJsonObject().get("nested").isJsonObject()); + assertEquals("innerValue", result.getAsJsonObject().get("nested").getAsJsonObject().get("innerKey").getAsString()); + } + + // ==================== Truthiness Tests ==================== + + @Test + public void truthinessNull() { + assertFalse(NbtJmesPathEvaluator.isTruthy(null)); + assertFalse(NbtJmesPathEvaluator.isTruthy(JsonNull.INSTANCE)); + } + + @Test + public void truthinessBooleanFalse() { + assertFalse(NbtJmesPathEvaluator.isTruthy(new JsonPrimitive(false))); + } + + @Test + public void truthinessBooleanTrue() { + assertTrue(NbtJmesPathEvaluator.isTruthy(new JsonPrimitive(true))); + } + + @Test + public void truthinessZero() { + assertFalse(NbtJmesPathEvaluator.isTruthy(new JsonPrimitive(0))); + assertFalse(NbtJmesPathEvaluator.isTruthy(new JsonPrimitive(0.0))); + } + + @Test + public void truthinessNonZeroNumber() { + assertTrue(NbtJmesPathEvaluator.isTruthy(new JsonPrimitive(1))); + assertTrue(NbtJmesPathEvaluator.isTruthy(new JsonPrimitive(-1))); + assertTrue(NbtJmesPathEvaluator.isTruthy(new JsonPrimitive(3.14))); + } + + @Test + public void truthinessEmptyString() { + assertFalse(NbtJmesPathEvaluator.isTruthy(new JsonPrimitive(""))); + } + + @Test + public void truthinessNonEmptyString() { + assertTrue(NbtJmesPathEvaluator.isTruthy(new JsonPrimitive("hello"))); + assertTrue(NbtJmesPathEvaluator.isTruthy(new JsonPrimitive(" "))); + } + + @Test + public void truthinessEmptyArray() { + assertFalse(NbtJmesPathEvaluator.isTruthy(new com.google.gson.JsonArray())); + } + + @Test + public void truthinessNonEmptyArray() { + var array = new com.google.gson.JsonArray(); + array.add(1); + assertTrue(NbtJmesPathEvaluator.isTruthy(array)); + } + + @Test + public void truthinessEmptyObject() { + assertFalse(NbtJmesPathEvaluator.isTruthy(new com.google.gson.JsonObject())); + } + + @Test + public void truthinessNonEmptyObject() { + var obj = new com.google.gson.JsonObject(); + obj.addProperty("key", "value"); + assertTrue(NbtJmesPathEvaluator.isTruthy(obj)); + } + + // ==================== JMESPath Evaluation Tests ==================== + + @Test + public void jmesPathSimpleFieldAccess() { + CompoundTag tag = new CompoundTag(); + tag.putInt("Damage", 50); + + NbtJmesPathEvaluator evaluator = NbtJmesPathEvaluator.compile("Damage"); + assertTrue(evaluator.matchesNbt(tag)); + } + + @Test + public void jmesPathMissingField() { + CompoundTag tag = new CompoundTag(); + tag.putInt("Other", 50); + + NbtJmesPathEvaluator evaluator = NbtJmesPathEvaluator.compile("Damage"); + assertFalse(evaluator.matchesNbt(tag)); + } + + @Test + public void jmesPathComparison() { + // JMESPath comparison operators return boolean true/false + CompoundTag tag = new CompoundTag(); + tag.putInt("Damage", 50); + + // Use comparison expression that returns boolean + NbtJmesPathEvaluator greaterThan10 = NbtJmesPathEvaluator.compile("Damage > `10`"); + NbtJmesPathEvaluator greaterThan100 = NbtJmesPathEvaluator.compile("Damage > `100`"); + + assertTrue(greaterThan10.matchesNbt(tag)); + assertFalse(greaterThan100.matchesNbt(tag)); + } + + @Test + public void jmesPathArrayAccess() { + CompoundTag tag = new CompoundTag(); + ListTag enchantments = new ListTag(); + CompoundTag enchant1 = new CompoundTag(); + enchant1.putString("id", "minecraft:sharpness"); + enchant1.putInt("lvl", 5); + enchantments.add(enchant1); + tag.put("Enchantments", enchantments); + + NbtJmesPathEvaluator evaluator = NbtJmesPathEvaluator.compile("Enchantments[0]"); + assertTrue(evaluator.matchesNbt(tag)); + } + + @Test + public void jmesPathEmptyArrayAccess() { + CompoundTag tag = new CompoundTag(); + tag.put("Enchantments", new ListTag()); + + NbtJmesPathEvaluator evaluator = NbtJmesPathEvaluator.compile("Enchantments[0]"); + assertFalse(evaluator.matchesNbt(tag)); + } + + @Test + public void jmesPathNestedFieldAccess() { + CompoundTag tag = new CompoundTag(); + CompoundTag display = new CompoundTag(); + display.putString("Name", "Test Item"); + tag.put("display", display); + + NbtJmesPathEvaluator evaluator = NbtJmesPathEvaluator.compile("display.Name"); + assertTrue(evaluator.matchesNbt(tag)); + } + + @Test + public void jmesPathNullTag() { + NbtJmesPathEvaluator evaluator = NbtJmesPathEvaluator.compile("Damage"); + assertFalse(evaluator.matchesNbt(null)); + } + + @Test + public void jmesPathFilterExpression() { + CompoundTag tag = new CompoundTag(); + ListTag enchantments = new ListTag(); + + CompoundTag enchant1 = new CompoundTag(); + enchant1.putString("id", "minecraft:sharpness"); + enchant1.putInt("lvl", 5); + enchantments.add(enchant1); + + CompoundTag enchant2 = new CompoundTag(); + enchant2.putString("id", "minecraft:unbreaking"); + enchant2.putInt("lvl", 3); + enchantments.add(enchant2); + + tag.put("Enchantments", enchantments); + + // Filter for sharpness enchantment + NbtJmesPathEvaluator sharpnessFilter = NbtJmesPathEvaluator.compile( + "Enchantments[?id == 'minecraft:sharpness']" + ); + assertTrue(sharpnessFilter.matchesNbt(tag)); + + // Filter for non-existent enchantment + NbtJmesPathEvaluator fireAspectFilter = NbtJmesPathEvaluator.compile( + "Enchantments[?id == 'minecraft:fire_aspect']" + ); + assertFalse(fireAspectFilter.matchesNbt(tag)); + } + + @Test + public void jmesPathInvalidExpressionThrows() { + assertThrows(ParseException.class, () -> { + NbtJmesPathEvaluator.compile("[?unclosed bracket"); + }); + } +} From 864d7240045697a3c4916f95eb9b029f24d52739 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 00:07:57 +0100 Subject: [PATCH 07/20] feat: introduce nbt expression grammar syntax for filtering --- src/main/antlr/sfml/SFML.g4 | 63 ++++++++++++++++--- .../syntaxes/SFML.g4 | 63 ++++++++++++++++--- 2 files changed, 108 insertions(+), 18 deletions(-) diff --git a/src/main/antlr/sfml/SFML.g4 b/src/main/antlr/sfml/SFML.g4 index bb3290759..5d418b3df 100644 --- a/src/main/antlr/sfml/SFML.g4 +++ b/src/main/antlr/sfml/SFML.g4 @@ -75,7 +75,46 @@ withClause : LPAREN withClause RPAREN # WithParen | withClause AND withClause # WithConjunction | withClause OR withClause # WithDisjunction | (TAG HASHTAG?|HASHTAG) tagMatcher # WithTag - | NBT string # WithNbt + | NBT string # WithNbtRaw + | NBT nbtExpr # WithNbtExpr + ; + +// NBT expression with optional comparison +nbtExpr : nbtPath (comparisonOp nbtValue)? + ; + +// Path starting with component, optional array index, then field/array access +nbtPath : nbtComponent (LBRACKET arrayIndex RBRACKET)? (DOT nbtPathElement)* + ; + +// Component: namespace:name or just name +nbtComponent: identifier (COLON identifier)? + ; + +// Path elements: field, field[n], or [n] +nbtPathElement : identifier (LBRACKET arrayIndex RBRACKET)? + | LBRACKET arrayIndex RBRACKET + ; + +// Array index: number, *, or ?filter +arrayIndex : NUMBER # ArrayIndexNumber + | STAR # ArrayIndexStar + | QUESTION nbtFilterExpr # ArrayIndexFilter + ; + +// Filter inside [?...] +nbtFilterExpr : nbtFilterPath (comparisonOp nbtValue)? + ; + +nbtFilterPath : AT? (DOT? identifier)+ + ; + +// Values for comparison +nbtValue : NUMBER # NbtValueNumber + | DASH NUMBER # NbtValueNegativeNumber + | string # NbtValueString + | TRUE # NbtValueTrue + | FALSE # NbtValueFalse ; tagMatcher : identifier COLON identifier (SLASH identifier)* @@ -151,7 +190,7 @@ label : (identifier) #RawLabel emptyslots : EMPTY (SLOTS | SLOT) IN ; -identifier : (IDENTIFIER | REDSTONE | GLOBAL | SECOND | SECONDS | TOP | BOTTOM | LEFT | RIGHT | FRONT | BACK) ; +identifier : (IDENTIFIER | REDSTONE | GLOBAL | SECOND | SECONDS | TOP | BOTTOM | LEFT | RIGHT | FRONT | BACK | STAR | NAME) ; // GENERAL string: STRING ; @@ -258,17 +297,23 @@ NAME : N A M E ; // used by triggers and as a set operator EVERY : E V E R Y ; -COMMA : ','; -COLON : ':'; -SLASH : '/'; -DASH : '-'; -LPAREN : '('; -RPAREN : ')'; +COMMA : ','; +COLON : ':'; +SLASH : '/'; +DASH : '-'; +LPAREN : '('; +RPAREN : ')'; +LBRACKET : '['; +RBRACKET : ']'; +DOT : '.'; +AT : '@'; +QUESTION : '?'; +STAR : '*'; NUMBER_WITH_G_SUFFIX : [0-9]+[gG] ; NUMBER : [0-9]+ ; -IDENTIFIER : [a-zA-Z_*][a-zA-Z0-9_*]* | '*'; // Note that the * in the square brackets is a literl +IDENTIFIER : [a-zA-Z_][a-zA-Z0-9_]*; STRING : '"' (~'"'|'\\"')* '"' ; diff --git a/vscodeextension/super-factory-manager-language/syntaxes/SFML.g4 b/vscodeextension/super-factory-manager-language/syntaxes/SFML.g4 index 38b5de0ba..988ef34fd 100644 --- a/vscodeextension/super-factory-manager-language/syntaxes/SFML.g4 +++ b/vscodeextension/super-factory-manager-language/syntaxes/SFML.g4 @@ -76,7 +76,46 @@ withClause : LPAREN withClause RPAREN # WithParen | withClause AND withClause # WithConjunction | withClause OR withClause # WithDisjunction | (TAG HASHTAG?|HASHTAG) tagMatcher # WithTag - | NBT string # WithNbt + | NBT string # WithNbtRaw + | NBT nbtExpr # WithNbtExpr + ; + +// NBT expression with optional comparison +nbtExpr : nbtPath (comparisonOp nbtValue)? + ; + +// Path starting with component, optional array index, then field/array access +nbtPath : nbtComponent (LBRACKET arrayIndex RBRACKET)? (DOT nbtPathElement)* + ; + +// Component: namespace:name or just name +nbtComponent: identifier (COLON identifier)? + ; + +// Path elements: field, field[n], or [n] +nbtPathElement : identifier (LBRACKET arrayIndex RBRACKET)? + | LBRACKET arrayIndex RBRACKET + ; + +// Array index: number, *, or ?filter +arrayIndex : NUMBER # ArrayIndexNumber + | STAR # ArrayIndexStar + | QUESTION nbtFilterExpr # ArrayIndexFilter + ; + +// Filter inside [?...] +nbtFilterExpr : nbtFilterPath (comparisonOp nbtValue)? + ; + +nbtFilterPath : AT? (DOT? identifier)+ + ; + +// Values for comparison +nbtValue : NUMBER # NbtValueNumber + | DASH NUMBER # NbtValueNegativeNumber + | string # NbtValueString + | TRUE # NbtValueTrue + | FALSE # NbtValueFalse ; tagMatcher : identifier COLON identifier (SLASH identifier)* @@ -152,7 +191,7 @@ label : (identifier) #RawLabel emptyslots : EMPTY (SLOTS | SLOT) IN ; -identifier : (IDENTIFIER | REDSTONE | GLOBAL | SECOND | SECONDS | TOP | BOTTOM | LEFT | RIGHT | FRONT | BACK) ; +identifier : (IDENTIFIER | REDSTONE | GLOBAL | SECOND | SECONDS | TOP | BOTTOM | LEFT | RIGHT | FRONT | BACK | STAR | NAME) ; // GENERAL string: STRING ; @@ -259,17 +298,23 @@ NAME : N A M E ; // used by triggers and as a set operator EVERY : E V E R Y ; -COMMA : ','; -COLON : ':'; -SLASH : '/'; -DASH : '-'; -LPAREN : '('; -RPAREN : ')'; +COMMA : ','; +COLON : ':'; +SLASH : '/'; +DASH : '-'; +LPAREN : '('; +RPAREN : ')'; +LBRACKET : '['; +RBRACKET : ']'; +DOT : '.'; +AT : '@'; +QUESTION : '?'; +STAR : '*'; NUMBER_WITH_G_SUFFIX : [0-9]+[gG] ; NUMBER : [0-9]+ ; -IDENTIFIER : [a-zA-Z_*][a-zA-Z0-9_*]* | '*'; // Note that the * in the square brackets is a literl +IDENTIFIER : [a-zA-Z_][a-zA-Z0-9_]*; STRING : '"' (~'"'|'\\"')* '"' ; From 6aaf8aa80393796fb5b54980635b22a3a15f80a3 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 00:09:29 +0100 Subject: [PATCH 08/20] feat: implement nbt expression ast nodes with jmespath conversion --- .../java/ca/teamdman/sfml/ast/ArrayIndex.java | 52 +++++++++++ .../ca/teamdman/sfml/ast/NbtComponent.java | 80 ++++++++++++++++ .../java/ca/teamdman/sfml/ast/NbtExpr.java | 59 ++++++++++++ .../ca/teamdman/sfml/ast/NbtFilterExpr.java | 52 +++++++++++ .../ca/teamdman/sfml/ast/NbtFilterPath.java | 65 +++++++++++++ .../java/ca/teamdman/sfml/ast/NbtPath.java | 64 +++++++++++++ .../ca/teamdman/sfml/ast/NbtPathElement.java | 92 +++++++++++++++++++ .../java/ca/teamdman/sfml/ast/NbtValue.java | 53 +++++++++++ .../ca/teamdman/sfml/ast/WithNbtExpr.java | 63 +++++++++++++ 9 files changed, 580 insertions(+) create mode 100644 src/main/java/ca/teamdman/sfml/ast/ArrayIndex.java create mode 100644 src/main/java/ca/teamdman/sfml/ast/NbtComponent.java create mode 100644 src/main/java/ca/teamdman/sfml/ast/NbtExpr.java create mode 100644 src/main/java/ca/teamdman/sfml/ast/NbtFilterExpr.java create mode 100644 src/main/java/ca/teamdman/sfml/ast/NbtFilterPath.java create mode 100644 src/main/java/ca/teamdman/sfml/ast/NbtPath.java create mode 100644 src/main/java/ca/teamdman/sfml/ast/NbtPathElement.java create mode 100644 src/main/java/ca/teamdman/sfml/ast/NbtValue.java create mode 100644 src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java diff --git a/src/main/java/ca/teamdman/sfml/ast/ArrayIndex.java b/src/main/java/ca/teamdman/sfml/ast/ArrayIndex.java new file mode 100644 index 000000000..c54d5aac8 --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/ArrayIndex.java @@ -0,0 +1,52 @@ +package ca.teamdman.sfml.ast; + +/** + * AST node representing an array index in NBT path expressions. + * Can be a number (e.g., [0]), star (e.g., [*]), or filter (e.g., [?id == "minecraft:sharpness"]). + */ +public sealed interface ArrayIndex extends ASTNode permits + ArrayIndex.NumberIndex, + ArrayIndex.StarIndex, + ArrayIndex.FilterIndex { + + /** + * Convert this array index to its JMESPath representation (without brackets). + */ + String toJmesPath(); + + record NumberIndex(long value) implements ArrayIndex { + @Override + public String toJmesPath() { + return String.valueOf(value); + } + + @Override + public String toString() { + return String.valueOf(value); + } + } + + record StarIndex() implements ArrayIndex { + @Override + public String toJmesPath() { + return "*"; + } + + @Override + public String toString() { + return "*"; + } + } + + record FilterIndex(NbtFilterExpr filter) implements ArrayIndex { + @Override + public String toJmesPath() { + return "?" + filter.toJmesPath(); + } + + @Override + public String toString() { + return "?" + filter; + } + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/NbtComponent.java b/src/main/java/ca/teamdman/sfml/ast/NbtComponent.java new file mode 100644 index 000000000..6682a199d --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/NbtComponent.java @@ -0,0 +1,80 @@ +package ca.teamdman.sfml.ast; + +import org.jetbrains.annotations.Nullable; + +/** + * AST node representing a component in an NBT path. + * Can be either a simple identifier (e.g., "damage") or a namespaced component (e.g., "minecraft:custom_data"). + * + * In JMESPath, identifiers containing special characters like colons must be quoted. + */ +public record NbtComponent( + String name, + @Nullable String namespace +) implements ASTNode { + + /** + * Creates a simple component without namespace. + */ + public static NbtComponent simple(String name) { + return new NbtComponent(name, null); + } + + /** + * Creates a namespaced component. + */ + public static NbtComponent namespaced(String namespace, String name) { + return new NbtComponent(name, namespace); + } + + /** + * Check if this is a namespaced component. + */ + public boolean hasNamespace() { + return namespace != null; + } + + /** + * Get the full identifier including namespace if present. + */ + public String getFullName() { + return hasNamespace() ? namespace + ":" + name : name; + } + + /** + * Convert this component to its JMESPath representation. + * Namespaced components need to be quoted in JMESPath. + */ + public String toJmesPath() { + if (hasNamespace()) { + // Quote the full name for JMESPath + return "\"" + namespace + ":" + name + "\""; + } + // Check if the name needs quoting (contains special characters) + if (needsQuoting(name)) { + return "\"" + name + "\""; + } + return name; + } + + /** + * Check if an identifier needs quoting in JMESPath. + */ + private static boolean needsQuoting(String identifier) { + if (identifier.isEmpty()) return true; + // JMESPath identifiers must start with a letter or underscore + // and contain only letters, digits, and underscores + char first = identifier.charAt(0); + if (!Character.isLetter(first) && first != '_') return true; + for (int i = 1; i < identifier.length(); i++) { + char c = identifier.charAt(i); + if (!Character.isLetterOrDigit(c) && c != '_') return true; + } + return false; + } + + @Override + public String toString() { + return getFullName(); + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/NbtExpr.java b/src/main/java/ca/teamdman/sfml/ast/NbtExpr.java new file mode 100644 index 000000000..69a713b81 --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/NbtExpr.java @@ -0,0 +1,59 @@ +package ca.teamdman.sfml.ast; + +import org.jetbrains.annotations.Nullable; + +/** + * AST node representing an NBT expression with optional comparison. + * Examples: damage, damage > 10, productivebees:gene_group.purity == 100 + */ +public record NbtExpr( + NbtPath path, + @Nullable ComparisonOperator operator, + @Nullable NbtValue value +) implements ASTNode { + + /** + * Create a path-only expression (existence check). + */ + public static NbtExpr pathOnly(NbtPath path) { + return new NbtExpr(path, null, null); + } + + /** + * Check if this expression has a comparison. + */ + public boolean hasComparison() { + return operator != null && value != null; + } + + /** + * Convert this expression to its JMESPath representation. + */ + public String toJmesPath() { + if (hasComparison()) { + return path.toJmesPath() + " " + toJmesPathOperator(operator) + " " + value.toJmesPath(); + } + return path.toJmesPath(); + } + + /** + * Convert comparison operator to JMESPath syntax. + */ + private static String toJmesPathOperator(ComparisonOperator op) { + return switch (op) { + case GREATER -> ">"; + case LESSER -> "<"; + case EQUALS -> "=="; + case LESSER_OR_EQUAL -> "<="; + case GREATER_OR_EQUAL -> ">="; + }; + } + + @Override + public String toString() { + if (hasComparison()) { + return path + " " + operator + " " + value; + } + return path.toString(); + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/NbtFilterExpr.java b/src/main/java/ca/teamdman/sfml/ast/NbtFilterExpr.java new file mode 100644 index 000000000..bd229a9d3 --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/NbtFilterExpr.java @@ -0,0 +1,52 @@ +package ca.teamdman.sfml.ast; + +import org.jetbrains.annotations.Nullable; + +/** + * AST node representing a filter expression inside array brackets [?...]. + * Examples: [?id == "minecraft:sharpness"], [?lvl > 3] + */ +public record NbtFilterExpr( + NbtFilterPath path, + @Nullable ComparisonOperator operator, + @Nullable NbtValue value +) implements ASTNode { + + /** + * Check if this filter has a comparison. + */ + public boolean hasComparison() { + return operator != null && value != null; + } + + /** + * Convert this filter expression to its JMESPath representation. + */ + public String toJmesPath() { + if (hasComparison()) { + return path.toJmesPath() + " " + toJmesPathOperator(operator) + " " + value.toJmesPath(); + } + return path.toJmesPath(); + } + + /** + * Convert comparison operator to JMESPath syntax. + */ + private static String toJmesPathOperator(ComparisonOperator op) { + return switch (op) { + case GREATER -> ">"; + case LESSER -> "<"; + case EQUALS -> "=="; + case LESSER_OR_EQUAL -> "<="; + case GREATER_OR_EQUAL -> ">="; + }; + } + + @Override + public String toString() { + if (hasComparison()) { + return path + " " + operator + " " + value; + } + return path.toString(); + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/NbtFilterPath.java b/src/main/java/ca/teamdman/sfml/ast/NbtFilterPath.java new file mode 100644 index 000000000..f74f7ec8c --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/NbtFilterPath.java @@ -0,0 +1,65 @@ +package ca.teamdman.sfml.ast; + +import java.util.List; + +/** + * AST node representing a path within an NBT filter expression. + * Examples: @.id, id, @.level, name.first + */ +public record NbtFilterPath( + boolean hasAt, + List identifiers +) implements ASTNode { + + /** + * Convert this filter path to its JMESPath representation. + */ + public String toJmesPath() { + StringBuilder sb = new StringBuilder(); + if (hasAt) { + sb.append("@"); + } + for (int i = 0; i < identifiers.size(); i++) { + String id = identifiers.get(i); + if (i > 0 || hasAt) { + sb.append("."); + } + // Quote if necessary + if (needsQuoting(id)) { + sb.append("\"").append(id).append("\""); + } else { + sb.append(id); + } + } + return sb.toString(); + } + + /** + * Check if an identifier needs quoting in JMESPath. + */ + private static boolean needsQuoting(String identifier) { + if (identifier.isEmpty()) return true; + char first = identifier.charAt(0); + if (!Character.isLetter(first) && first != '_') return true; + for (int i = 1; i < identifier.length(); i++) { + char c = identifier.charAt(i); + if (!Character.isLetterOrDigit(c) && c != '_') return true; + } + return false; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + if (hasAt) { + sb.append("@"); + } + for (int i = 0; i < identifiers.size(); i++) { + if (i > 0 || hasAt) { + sb.append("."); + } + sb.append(identifiers.get(i)); + } + return sb.toString(); + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/NbtPath.java b/src/main/java/ca/teamdman/sfml/ast/NbtPath.java new file mode 100644 index 000000000..d1125ec90 --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/NbtPath.java @@ -0,0 +1,64 @@ +package ca.teamdman.sfml.ast; + +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * AST node representing a full NBT path expression. + * Examples: damage, productivebees:gene_group.purity, enchantments[0].id + */ +public record NbtPath( + NbtComponent component, + @Nullable ArrayIndex componentArrayIndex, + List elements +) implements ASTNode { + + /** + * Create a simple path with just a component. + */ + public static NbtPath simple(NbtComponent component) { + return new NbtPath(component, null, List.of()); + } + + /** + * Check if this path has a component array index. + */ + public boolean hasComponentArrayIndex() { + return componentArrayIndex != null; + } + + /** + * Convert this path to its JMESPath representation. + */ + public String toJmesPath() { + StringBuilder sb = new StringBuilder(); + sb.append(component.toJmesPath()); + if (hasComponentArrayIndex()) { + sb.append("[").append(componentArrayIndex.toJmesPath()).append("]"); + } + for (NbtPathElement element : elements) { + if (element.hasField()) { + sb.append("."); + } + sb.append(element.toJmesPath()); + } + return sb.toString(); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(component); + if (hasComponentArrayIndex()) { + sb.append("[").append(componentArrayIndex).append("]"); + } + for (NbtPathElement element : elements) { + if (element.hasField()) { + sb.append("."); + } + sb.append(element); + } + return sb.toString(); + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/NbtPathElement.java b/src/main/java/ca/teamdman/sfml/ast/NbtPathElement.java new file mode 100644 index 000000000..f981e0765 --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/NbtPathElement.java @@ -0,0 +1,92 @@ +package ca.teamdman.sfml.ast; + +import org.jetbrains.annotations.Nullable; + +/** + * AST node representing an element in an NBT path after the initial component. + * Examples: .field, .field[0], [0] + */ +public record NbtPathElement( + @Nullable String field, + @Nullable ArrayIndex arrayIndex +) implements ASTNode { + + /** + * Create a field-only element. + */ + public static NbtPathElement field(String field) { + return new NbtPathElement(field, null); + } + + /** + * Create a field with array index element. + */ + public static NbtPathElement fieldWithIndex(String field, ArrayIndex index) { + return new NbtPathElement(field, index); + } + + /** + * Create an array-only element. + */ + public static NbtPathElement arrayOnly(ArrayIndex index) { + return new NbtPathElement(null, index); + } + + /** + * Check if this element has a field. + */ + public boolean hasField() { + return field != null; + } + + /** + * Check if this element has an array index. + */ + public boolean hasArrayIndex() { + return arrayIndex != null; + } + + /** + * Convert this path element to its JMESPath representation. + */ + public String toJmesPath() { + StringBuilder sb = new StringBuilder(); + if (hasField()) { + if (needsQuoting(field)) { + sb.append("\"").append(field).append("\""); + } else { + sb.append(field); + } + } + if (hasArrayIndex()) { + sb.append("[").append(arrayIndex.toJmesPath()).append("]"); + } + return sb.toString(); + } + + /** + * Check if an identifier needs quoting in JMESPath. + */ + private static boolean needsQuoting(String identifier) { + if (identifier.isEmpty()) return true; + char first = identifier.charAt(0); + if (!Character.isLetter(first) && first != '_') return true; + for (int i = 1; i < identifier.length(); i++) { + char c = identifier.charAt(i); + if (!Character.isLetterOrDigit(c) && c != '_') return true; + } + return false; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + if (hasField()) { + sb.append(field); + } + if (hasArrayIndex()) { + sb.append("[").append(arrayIndex).append("]"); + } + return sb.toString(); + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/NbtValue.java b/src/main/java/ca/teamdman/sfml/ast/NbtValue.java new file mode 100644 index 000000000..c4a21860f --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/NbtValue.java @@ -0,0 +1,53 @@ +package ca.teamdman.sfml.ast; + +/** + * AST node representing a value in NBT expressions (number, string, or boolean). + * Used in comparisons like: damage > 10, id == "minecraft:sharpness", etc. + */ +public sealed interface NbtValue extends ASTNode permits + NbtValue.NbtNumber, + NbtValue.NbtString, + NbtValue.NbtBoolean { + + /** + * Convert this value to its JMESPath representation. + */ + String toJmesPath(); + + record NbtNumber(long value) implements NbtValue { + @Override + public String toJmesPath() { + return "`" + value + "`"; + } + + @Override + public String toString() { + return String.valueOf(value); + } + } + + record NbtString(String value) implements NbtValue { + @Override + public String toJmesPath() { + // JMESPath uses single quotes for string literals + return "'" + value.replace("'", "\\'") + "'"; + } + + @Override + public String toString() { + return "\"" + value.replace("\"", "\\\"") + "\""; + } + } + + record NbtBoolean(boolean value) implements NbtValue { + @Override + public String toJmesPath() { + return "`" + value + "`"; + } + + @Override + public String toString() { + return value ? "true" : "false"; + } + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java b/src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java new file mode 100644 index 000000000..e6a990179 --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java @@ -0,0 +1,63 @@ +package ca.teamdman.sfml.ast; + +import ca.teamdman.sfm.common.resourcetype.ResourceType; +import ca.teamdman.sfm.common.util.NbtJmesPathEvaluator; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.fluids.FluidStack; + +/** + * AST node for NBT filtering using grammar-based expressions. + * Example usage: INPUT WITH NBT damage > 10 FROM a + * + * This compiles the grammar-based expression to JMESPath for evaluation. + */ +public record WithNbtExpr( + NbtExpr expression, + String jmesPathExpression, + NbtJmesPathEvaluator evaluator +) implements ASTNode, WithClause, ToStringPretty { + + /** + * Creates a WithNbtExpr node from an NbtExpr, compiling to JMESPath. + * + * @param expression The NBT expression + * @return A new WithNbtExpr node + * @throws io.burt.jmespath.parser.ParseException if the compiled expression is invalid + */ + public static WithNbtExpr create(NbtExpr expression) { + String jmesPath = expression.toJmesPath(); + NbtJmesPathEvaluator evaluator = NbtJmesPathEvaluator.compile(jmesPath); + return new WithNbtExpr(expression, jmesPath, evaluator); + } + + @Override + public boolean matchesStack( + ResourceType resourceType, + STACK stack + ) { + CompoundTag tag = getNbtFromStack(stack); + return evaluator.matchesNbt(tag); + } + + /** + * Extracts the NBT CompoundTag from a stack. + * Supports ItemStack and FluidStack. + * + * @param stack The stack to extract NBT from + * @return The CompoundTag, or null if the stack type is not supported or has no NBT + */ + private static CompoundTag getNbtFromStack(Object stack) { + if (stack instanceof ItemStack itemStack) { + return itemStack.getTag(); + } else if (stack instanceof FluidStack fluidStack) { + return fluidStack.getTag(); + } + return null; + } + + @Override + public String toString() { + return "NBT " + expression; + } +} From cab22b4d0745be321ef07d9cc52bc6de16a6b32b Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 00:09:44 +0100 Subject: [PATCH 09/20] feat: integrate nbt expression parsing into astbuilder --- .../java/ca/teamdman/sfml/ast/ASTBuilder.java | 165 +++++++++++++++++- 1 file changed, 164 insertions(+), 1 deletion(-) diff --git a/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java b/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java index bde05bda7..0c59b030e 100644 --- a/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java +++ b/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java @@ -690,7 +690,7 @@ public WithDisjunction visitWithDisjunction(SFMLParser.WithDisjunctionContext ct } @Override - public WithNbt visitWithNbt(SFMLParser.WithNbtContext ctx) { + public WithNbt visitWithNbtRaw(SFMLParser.WithNbtRawContext ctx) { String expression = visitString(ctx.string()).value(); WithNbt rtn = WithNbt.create(expression); @@ -698,6 +698,169 @@ public WithNbt visitWithNbt(SFMLParser.WithNbtContext ctx) { return rtn; } + @Override + public WithNbtExpr visitWithNbtExpr(SFMLParser.WithNbtExprContext ctx) { + + NbtExpr expr = visitNbtExpr(ctx.nbtExpr()); + WithNbtExpr rtn = WithNbtExpr.create(expr); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtExpr visitNbtExpr(SFMLParser.NbtExprContext ctx) { + + NbtPath path = visitNbtPath(ctx.nbtPath()); + ComparisonOperator op = null; + NbtValue value = null; + if (ctx.comparisonOp() != null && ctx.nbtValue() != null) { + op = visitComparisonOp(ctx.comparisonOp()); + value = (NbtValue) visit(ctx.nbtValue()); + } + NbtExpr rtn = new NbtExpr(path, op, value); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtPath visitNbtPath(SFMLParser.NbtPathContext ctx) { + + NbtComponent component = visitNbtComponent(ctx.nbtComponent()); + ArrayIndex componentArrayIndex = ctx.arrayIndex() != null ? (ArrayIndex) visit(ctx.arrayIndex()) : null; + List elements = ctx.nbtPathElement() + .stream() + .map(this::visitNbtPathElement) + .collect(Collectors.toList()); + NbtPath rtn = new NbtPath(component, componentArrayIndex, elements); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtComponent visitNbtComponent(SFMLParser.NbtComponentContext ctx) { + + List identifiers = ctx.identifier(); + NbtComponent rtn; + if (identifiers.size() == 2) { + // namespace:name + String namespace = identifiers.get(0).getText(); + String name = identifiers.get(1).getText(); + rtn = NbtComponent.namespaced(namespace, name); + } else { + // just name + rtn = NbtComponent.simple(identifiers.get(0).getText()); + } + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtPathElement visitNbtPathElement(SFMLParser.NbtPathElementContext ctx) { + + String field = ctx.identifier() != null ? ctx.identifier().getText() : null; + ArrayIndex index = ctx.arrayIndex() != null ? (ArrayIndex) visit(ctx.arrayIndex()) : null; + NbtPathElement rtn = new NbtPathElement(field, index); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public ArrayIndex visitArrayIndexNumber(SFMLParser.ArrayIndexNumberContext ctx) { + + long value = Long.parseLong(ctx.NUMBER().getText()); + ArrayIndex rtn = new ArrayIndex.NumberIndex(value); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public ArrayIndex visitArrayIndexStar(SFMLParser.ArrayIndexStarContext ctx) { + + ArrayIndex rtn = new ArrayIndex.StarIndex(); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public ArrayIndex visitArrayIndexFilter(SFMLParser.ArrayIndexFilterContext ctx) { + + NbtFilterExpr filter = visitNbtFilterExpr(ctx.nbtFilterExpr()); + ArrayIndex rtn = new ArrayIndex.FilterIndex(filter); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtFilterExpr visitNbtFilterExpr(SFMLParser.NbtFilterExprContext ctx) { + + NbtFilterPath path = visitNbtFilterPath(ctx.nbtFilterPath()); + ComparisonOperator op = null; + NbtValue value = null; + if (ctx.comparisonOp() != null && ctx.nbtValue() != null) { + op = visitComparisonOp(ctx.comparisonOp()); + value = (NbtValue) visit(ctx.nbtValue()); + } + NbtFilterExpr rtn = new NbtFilterExpr(path, op, value); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtFilterPath visitNbtFilterPath(SFMLParser.NbtFilterPathContext ctx) { + + boolean hasAt = ctx.AT() != null; + List identifiers = ctx.identifier() + .stream() + .map(ParseTree::getText) + .collect(Collectors.toList()); + NbtFilterPath rtn = new NbtFilterPath(hasAt, identifiers); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtValue visitNbtValueNumber(SFMLParser.NbtValueNumberContext ctx) { + + long value = Long.parseLong(ctx.NUMBER().getText()); + NbtValue rtn = new NbtValue.NbtNumber(value); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtValue visitNbtValueNegativeNumber(SFMLParser.NbtValueNegativeNumberContext ctx) { + + long value = -Long.parseLong(ctx.NUMBER().getText()); + NbtValue rtn = new NbtValue.NbtNumber(value); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtValue visitNbtValueString(SFMLParser.NbtValueStringContext ctx) { + + String value = visitString(ctx.string()).value(); + NbtValue rtn = new NbtValue.NbtString(value); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtValue visitNbtValueTrue(SFMLParser.NbtValueTrueContext ctx) { + + NbtValue rtn = new NbtValue.NbtBoolean(true); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtValue visitNbtValueFalse(SFMLParser.NbtValueFalseContext ctx) { + + NbtValue rtn = new NbtValue.NbtBoolean(false); + trackNode(rtn, ctx); + return rtn; + } + @Override public TagMatcher visitTagMatcher(SFMLParser.TagMatcherContext ctx) { From 513764fd19cc947c8541600c58b5b3c0b628bd9e Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 00:16:05 +0100 Subject: [PATCH 10/20] test: add nbt expression feature tests --- .../teamdman/sfml/SFMLNbtFilteringTests.java | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) diff --git a/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java b/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java index 162a56887..8630d76b2 100644 --- a/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java +++ b/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java @@ -1,6 +1,7 @@ package ca.teamdman.sfml; import ca.teamdman.sfm.common.util.NbtJmesPathEvaluator; +import ca.teamdman.sfml.ast.*; import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonPrimitive; @@ -8,6 +9,8 @@ import net.minecraft.nbt.*; import org.junit.jupiter.api.Test; +import java.util.List; + import static ca.teamdman.sfml.SFMLTestHelpers.*; import static org.junit.jupiter.api.Assertions.*; @@ -453,4 +456,300 @@ public void jmesPathInvalidExpressionThrows() { NbtJmesPathEvaluator.compile("[?unclosed bracket"); }); } + + // ==================== Grammar-based NBT Expression Tests ==================== + + @Test + public void nbtExprSimpleField() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT Damage FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprFieldComparison() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT Damage > 10 FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprFieldEquality() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT Damage = 0 FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprFieldLessOrEqual() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT Damage <= 100 FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprNamespacedComponent() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT minecraft:custom_data FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprNamespacedComponentWithComparison() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT productivebees:gene_group.purity = 100 FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprNestedField() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT display.Name FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprArrayAccess() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT Enchantments[0] FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprArrayAccessWithField() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT Enchantments[0].id FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprArrayWildcard() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT Enchantments[*].lvl FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprArrayFilter() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT Enchantments[?id = "minecraft:sharpness"] FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprArrayFilterWithComparison() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT Enchantments[?lvl > 3] FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprArrayFilterWithAt() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT Enchantments[?@.id = "minecraft:sharpness"] FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprNegativeNumber() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT temperature > -10 FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprBooleanValue() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT active = true FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprCombinedWithTag() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT Damage > 0 AND TAG minecraft:swords FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprOldSyntaxStillWorks() { + // Verify the old string-based syntax still works + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITH NBT "Enchantments[*].lvl | max(@) > `3`" FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprWithWithout() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT WITHOUT NBT Damage FROM a + OUTPUT TO b + END + """); + } + + @Test + public void nbtExprInBooleanHas() { + assertNoCompileErrors(""" + EVERY 20 TICKS DO + INPUT FROM a + IF a HAS > 0 diamond_sword WITH NBT Damage > 10 THEN + OUTPUT TO b + END + END + """); + } + + // ==================== JMESPath Compilation Tests ==================== + + @Test + public void nbtExprCompilesToSimplePath() { + NbtComponent component = NbtComponent.simple("Damage"); + NbtPath path = new NbtPath(component, null, List.of()); + NbtExpr expr = NbtExpr.pathOnly(path); + + assertEquals("Damage", expr.toJmesPath()); + } + + @Test + public void nbtExprCompilesToNamespacedPath() { + NbtComponent component = NbtComponent.namespaced("minecraft", "custom_data"); + NbtPath path = new NbtPath(component, null, List.of()); + NbtExpr expr = NbtExpr.pathOnly(path); + + assertEquals("\"minecraft:custom_data\"", expr.toJmesPath()); + } + + @Test + public void nbtExprCompilesToNestedPath() { + NbtComponent component = NbtComponent.simple("display"); + NbtPathElement element = NbtPathElement.field("Name"); + NbtPath path = new NbtPath(component, null, List.of(element)); + NbtExpr expr = NbtExpr.pathOnly(path); + + assertEquals("display.Name", expr.toJmesPath()); + } + + @Test + public void nbtExprCompilesToArrayPath() { + NbtComponent component = NbtComponent.simple("Enchantments"); + ArrayIndex.NumberIndex arrayIndex = new ArrayIndex.NumberIndex(0); + NbtPath path = new NbtPath(component, arrayIndex, List.of()); + NbtExpr expr = NbtExpr.pathOnly(path); + + assertEquals("Enchantments[0]", expr.toJmesPath()); + } + + @Test + public void nbtExprCompilesToComparison() { + NbtComponent component = NbtComponent.simple("Damage"); + NbtPath path = new NbtPath(component, null, List.of()); + NbtExpr expr = new NbtExpr(path, ComparisonOperator.GREATER, new NbtValue.NbtNumber(10)); + + assertEquals("Damage > `10`", expr.toJmesPath()); + } + + @Test + public void nbtExprCompilesToStringComparison() { + NbtFilterPath filterPath = new NbtFilterPath(false, List.of("id")); + NbtFilterExpr filterExpr = new NbtFilterExpr( + filterPath, + ComparisonOperator.EQUALS, + new NbtValue.NbtString("minecraft:sharpness") + ); + ArrayIndex.FilterIndex filterIndex = new ArrayIndex.FilterIndex(filterExpr); + NbtComponent component = NbtComponent.simple("Enchantments"); + NbtPath path = new NbtPath(component, filterIndex, List.of()); + NbtExpr expr = NbtExpr.pathOnly(path); + + assertEquals("Enchantments[?id == 'minecraft:sharpness']", expr.toJmesPath()); + } + + @Test + public void nbtExprCompilesNamespacedWithNestedField() { + NbtComponent component = NbtComponent.namespaced("productivebees", "gene_group"); + NbtPathElement element = NbtPathElement.field("purity"); + NbtPath path = new NbtPath(component, null, List.of(element)); + NbtExpr expr = new NbtExpr(path, ComparisonOperator.EQUALS, new NbtValue.NbtNumber(100)); + + assertEquals("\"productivebees:gene_group\".purity == `100`", expr.toJmesPath()); + } + + @Test + public void nbtValueNegativeNumber() { + NbtValue value = new NbtValue.NbtNumber(-42); + assertEquals("`-42`", value.toJmesPath()); + } + + @Test + public void nbtValueBoolean() { + NbtValue trueVal = new NbtValue.NbtBoolean(true); + NbtValue falseVal = new NbtValue.NbtBoolean(false); + assertEquals("`true`", trueVal.toJmesPath()); + assertEquals("`false`", falseVal.toJmesPath()); + } + + @Test + public void nbtFilterPathWithAt() { + NbtFilterPath path = new NbtFilterPath(true, List.of("id")); + assertEquals("@.id", path.toJmesPath()); + } + + @Test + public void nbtFilterPathWithoutAt() { + NbtFilterPath path = new NbtFilterPath(false, List.of("nested", "field")); + assertEquals("nested.field", path.toJmesPath()); + } } From c84b4cc99471c3ae6f6e8166556fa5c473c71064 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 00:29:11 +0100 Subject: [PATCH 11/20] refactor: move getnbtfromstack to nbtjmespathevaluator --- .../sfm/common/util/NbtJmesPathEvaluator.java | 16 ++++++++++++++ .../java/ca/teamdman/sfml/ast/WithNbt.java | 22 +------------------ .../ca/teamdman/sfml/ast/WithNbtExpr.java | 22 +------------------ 3 files changed, 18 insertions(+), 42 deletions(-) diff --git a/src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java b/src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java index 88a1b5034..e914d89a3 100644 --- a/src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java +++ b/src/main/java/ca/teamdman/sfm/common/util/NbtJmesPathEvaluator.java @@ -67,6 +67,22 @@ public boolean matchesNbt(@Nullable CompoundTag tag) { return isTruthy(result); } + /** + * Extracts the NBT CompoundTag from a stack. + * Supports ItemStack and FluidStack. + * + * @param stack The stack to extract NBT from + * @return The CompoundTag, or null if the stack type is not supported or has no NBT + */ + public static @Nullable CompoundTag getNbtFromStack(Object stack) { + if (stack instanceof ItemStack itemStack) { + return itemStack.getTag(); + } else if (stack instanceof FluidStack fluidStack) { + return fluidStack.getTag(); + } + return null; + } + /** * Converts a Minecraft NBT Tag to a Gson JsonElement. * diff --git a/src/main/java/ca/teamdman/sfml/ast/WithNbt.java b/src/main/java/ca/teamdman/sfml/ast/WithNbt.java index ab9ae4950..5e78e5ed1 100644 --- a/src/main/java/ca/teamdman/sfml/ast/WithNbt.java +++ b/src/main/java/ca/teamdman/sfml/ast/WithNbt.java @@ -2,9 +2,6 @@ import ca.teamdman.sfm.common.resourcetype.ResourceType; import ca.teamdman.sfm.common.util.NbtJmesPathEvaluator; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.world.item.ItemStack; -import net.minecraftforge.fluids.FluidStack; /** * AST node for NBT filtering using JMESPath expressions. @@ -32,24 +29,7 @@ public boolean matchesStack( ResourceType resourceType, STACK stack ) { - CompoundTag tag = getNbtFromStack(stack); - return evaluator.matchesNbt(tag); - } - - /** - * Extracts the NBT CompoundTag from a stack. - * Supports ItemStack and FluidStack. - * - * @param stack The stack to extract NBT from - * @return The CompoundTag, or null if the stack type is not supported or has no NBT - */ - private static CompoundTag getNbtFromStack(Object stack) { - if (stack instanceof ItemStack itemStack) { - return itemStack.getTag(); - } else if (stack instanceof FluidStack fluidStack) { - return fluidStack.getTag(); - } - return null; + return evaluator.matchesNbt(NbtJmesPathEvaluator.getNbtFromStack(stack)); } @Override diff --git a/src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java b/src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java index e6a990179..c35f6557c 100644 --- a/src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java +++ b/src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java @@ -2,9 +2,6 @@ import ca.teamdman.sfm.common.resourcetype.ResourceType; import ca.teamdman.sfm.common.util.NbtJmesPathEvaluator; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.world.item.ItemStack; -import net.minecraftforge.fluids.FluidStack; /** * AST node for NBT filtering using grammar-based expressions. @@ -36,24 +33,7 @@ public boolean matchesStack( ResourceType resourceType, STACK stack ) { - CompoundTag tag = getNbtFromStack(stack); - return evaluator.matchesNbt(tag); - } - - /** - * Extracts the NBT CompoundTag from a stack. - * Supports ItemStack and FluidStack. - * - * @param stack The stack to extract NBT from - * @return The CompoundTag, or null if the stack type is not supported or has no NBT - */ - private static CompoundTag getNbtFromStack(Object stack) { - if (stack instanceof ItemStack itemStack) { - return itemStack.getTag(); - } else if (stack instanceof FluidStack fluidStack) { - return fluidStack.getTag(); - } - return null; + return evaluator.matchesNbt(NbtJmesPathEvaluator.getNbtFromStack(stack)); } @Override From e4714d11567fdbadedf260fc777bb4ee2f69cf40 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 10:34:20 +0100 Subject: [PATCH 12/20] fix: support wildcard patterns in resource identifiers and tag matchers --- src/main/antlr/sfml/SFML.g4 | 28 ++++++++++++++++--- .../java/ca/teamdman/sfml/ast/ASTBuilder.java | 2 +- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/main/antlr/sfml/SFML.g4 b/src/main/antlr/sfml/SFML.g4 index 5d418b3df..f9ece77bf 100644 --- a/src/main/antlr/sfml/SFML.g4 +++ b/src/main/antlr/sfml/SFML.g4 @@ -59,7 +59,7 @@ retention : RETAIN number EACH?; resourceExclusion : EXCEPT resourceIdList; -resourceId : (identifier) (COLON (identifier)? (COLON (identifier)? (COLON (identifier)?)?)?)? # Resource +resourceId : (identifierPattern) (COLON (identifierPattern)? (COLON (identifierPattern)? (COLON (identifierPattern)?)?)?)? # Resource | string # StringResource ; @@ -117,10 +117,15 @@ nbtValue : NUMBER # NbtValueNumber | FALSE # NbtValueFalse ; -tagMatcher : identifier COLON identifier (SLASH identifier)* - | identifier (SLASH identifier)* +tagMatcher : tagPatternElement COLON tagPatternElement (SLASH tagPatternElement)* + | tagPatternElement (SLASH tagPatternElement)* ; +// Tag pattern element - supports wildcards like *, *_matter, foo*, and ** for multi-segment +tagPatternElement : STAR STAR // ** for multi-segment wildcard + | identifierPattern + ; + sidequalifier : EACH SIDE #EachSide | side (COMMA side)* SIDE #ListedSides @@ -190,7 +195,22 @@ label : (identifier) #RawLabel emptyslots : EMPTY (SLOTS | SLOT) IN ; -identifier : (IDENTIFIER | REDSTONE | GLOBAL | SECOND | SECONDS | TOP | BOTTOM | LEFT | RIGHT | FRONT | BACK | STAR | NAME) ; +// Pattern for resource identifiers - supports wildcards like *seed*, *seed, seed*, * +identifierPattern : STAR? identifierBase STAR? + | STAR + ; + +// Base identifiers (keywords that can be used as identifiers, without STAR) +// This includes all keywords that could appear in resource names (e.g., *block*, *iron*, etc.) +identifierBase : IDENTIFIER | REDSTONE | GLOBAL | SECOND | SECONDS | TOP | BOTTOM | LEFT | RIGHT | FRONT | BACK | NAME + | BLOCK | LABEL | SLOT | SLOTS | EMPTY | TAG | SIDE | NULL | TICK | TICKS | PULSE | ROUND | ROBIN + | NORTH | EAST | SOUTH | WEST | IF | THEN | ELSE | DO | END | TRUE | FALSE | NOT | AND | OR + | WITH | WITHOUT | NBT | BY | IN | FROM | TO | WHERE | RETAIN | EACH | EXCEPT | FORGET | HAS + | OVERALL | SOME | ONE | LONE | INPUT | OUTPUT | EVERY + ; + +// Full identifier including STAR (for labels, NBT paths, etc.) +identifier : identifierBase | STAR ; // GENERAL string: STRING ; diff --git a/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java b/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java index 0c59b030e..f37837692 100644 --- a/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java +++ b/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java @@ -865,7 +865,7 @@ public NbtValue visitNbtValueFalse(SFMLParser.NbtValueFalseContext ctx) { public TagMatcher visitTagMatcher(SFMLParser.TagMatcherContext ctx) { ArrayDeque identifiers = ctx - .identifier() + .tagPatternElement() .stream() .map(ParseTree::getText) .map(s -> s.replaceAll("\\*", ".*")) // convert * to .* From 81e5277c0372380fa8eda4054a4c46f16983e0db Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 11:07:02 +0100 Subject: [PATCH 13/20] feat: add IN operator for NBT array membership checks --- src/main/antlr/sfml/SFML.g4 | 9 +++- .../java/ca/teamdman/sfml/ast/ASTBuilder.java | 37 +++++++++++++--- .../ca/teamdman/sfml/ast/NbtArrayLiteral.java | 43 +++++++++++++++++++ .../java/ca/teamdman/sfml/ast/NbtExpr.java | 38 ++++++++++++++-- .../teamdman/sfml/SFMLNbtFilteringTests.java | 27 +++++++++++- 5 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 src/main/java/ca/teamdman/sfml/ast/NbtArrayLiteral.java diff --git a/src/main/antlr/sfml/SFML.g4 b/src/main/antlr/sfml/SFML.g4 index f9ece77bf..2c6698861 100644 --- a/src/main/antlr/sfml/SFML.g4 +++ b/src/main/antlr/sfml/SFML.g4 @@ -79,8 +79,9 @@ withClause : LPAREN withClause RPAREN # WithParen | NBT nbtExpr # WithNbtExpr ; -// NBT expression with optional comparison -nbtExpr : nbtPath (comparisonOp nbtValue)? +// NBT expression with optional comparison or IN check +nbtExpr : nbtPath (comparisonOp nbtValue)? # NbtComparison + | nbtPath IN nbtArray # NbtInArray ; // Path starting with component, optional array index, then field/array access @@ -117,6 +118,10 @@ nbtValue : NUMBER # NbtValueNumber | FALSE # NbtValueFalse ; +// Array literal for IN expressions +nbtArray : LBRACKET (nbtValue (COMMA nbtValue)*)? RBRACKET + ; + tagMatcher : tagPatternElement COLON tagPatternElement (SLASH tagPatternElement)* | tagPatternElement (SLASH tagPatternElement)* ; diff --git a/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java b/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java index f37837692..1c4317bf8 100644 --- a/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java +++ b/src/main/java/ca/teamdman/sfml/ast/ASTBuilder.java @@ -701,23 +701,46 @@ public WithNbt visitWithNbtRaw(SFMLParser.WithNbtRawContext ctx) { @Override public WithNbtExpr visitWithNbtExpr(SFMLParser.WithNbtExprContext ctx) { - NbtExpr expr = visitNbtExpr(ctx.nbtExpr()); + NbtExpr expr = (NbtExpr) visit(ctx.nbtExpr()); WithNbtExpr rtn = WithNbtExpr.create(expr); trackNode(rtn, ctx); return rtn; } @Override - public NbtExpr visitNbtExpr(SFMLParser.NbtExprContext ctx) { + public NbtExpr visitNbtComparison(SFMLParser.NbtComparisonContext ctx) { NbtPath path = visitNbtPath(ctx.nbtPath()); - ComparisonOperator op = null; - NbtValue value = null; + NbtExpr rtn; if (ctx.comparisonOp() != null && ctx.nbtValue() != null) { - op = visitComparisonOp(ctx.comparisonOp()); - value = (NbtValue) visit(ctx.nbtValue()); + ComparisonOperator op = visitComparisonOp(ctx.comparisonOp()); + NbtValue value = (NbtValue) visit(ctx.nbtValue()); + rtn = NbtExpr.comparison(path, op, value); + } else { + rtn = NbtExpr.pathOnly(path); } - NbtExpr rtn = new NbtExpr(path, op, value); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtExpr visitNbtInArray(SFMLParser.NbtInArrayContext ctx) { + + NbtPath path = visitNbtPath(ctx.nbtPath()); + NbtArrayLiteral array = visitNbtArray(ctx.nbtArray()); + NbtExpr rtn = NbtExpr.inArray(path, array); + trackNode(rtn, ctx); + return rtn; + } + + @Override + public NbtArrayLiteral visitNbtArray(SFMLParser.NbtArrayContext ctx) { + + List values = ctx.nbtValue() + .stream() + .map(v -> (NbtValue) visit(v)) + .collect(Collectors.toList()); + NbtArrayLiteral rtn = new NbtArrayLiteral(values); trackNode(rtn, ctx); return rtn; } diff --git a/src/main/java/ca/teamdman/sfml/ast/NbtArrayLiteral.java b/src/main/java/ca/teamdman/sfml/ast/NbtArrayLiteral.java new file mode 100644 index 000000000..70dd71aa1 --- /dev/null +++ b/src/main/java/ca/teamdman/sfml/ast/NbtArrayLiteral.java @@ -0,0 +1,43 @@ +package ca.teamdman.sfml.ast; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * AST node representing an array literal in NBT expressions. + * Used in IN expressions like: potion_contents.potion IN ["minecraft:water", "minecraft:mundane"] + */ +public record NbtArrayLiteral(List values) implements ASTNode { + + /** + * Convert this array to its JMESPath representation. + * JMESPath array literals: ['value1', 'value2'] for contains() function + */ + public String toJmesPath() { + String inner = values.stream() + .map(this::valueToJmesPathArrayElement) + .collect(Collectors.joining(", ")); + return "[" + inner + "]"; + } + + /** + * Convert a value to JMESPath array element format. + * Strings use single quotes, numbers and booleans use backticks. + */ + private String valueToJmesPathArrayElement(NbtValue value) { + if (value instanceof NbtValue.NbtString s) { + // Strings in array use single quotes directly (not backtick-wrapped) + return "'" + s.value().replace("'", "\\'") + "'"; + } + // Numbers and booleans use the standard format + return value.toJmesPath(); + } + + @Override + public String toString() { + String inner = values.stream() + .map(NbtValue::toString) + .collect(Collectors.joining(", ")); + return "[" + inner + "]"; + } +} diff --git a/src/main/java/ca/teamdman/sfml/ast/NbtExpr.java b/src/main/java/ca/teamdman/sfml/ast/NbtExpr.java index 69a713b81..de42f08b6 100644 --- a/src/main/java/ca/teamdman/sfml/ast/NbtExpr.java +++ b/src/main/java/ca/teamdman/sfml/ast/NbtExpr.java @@ -3,20 +3,36 @@ import org.jetbrains.annotations.Nullable; /** - * AST node representing an NBT expression with optional comparison. - * Examples: damage, damage > 10, productivebees:gene_group.purity == 100 + * AST node representing an NBT expression with optional comparison or IN check. + * Examples: damage, damage > 10, productivebees:gene_group.purity == 100, + * potion_contents.potion IN ["minecraft:water", "minecraft:mundane"] */ public record NbtExpr( NbtPath path, @Nullable ComparisonOperator operator, - @Nullable NbtValue value + @Nullable NbtValue value, + @Nullable NbtArrayLiteral array ) implements ASTNode { /** * Create a path-only expression (existence check). */ public static NbtExpr pathOnly(NbtPath path) { - return new NbtExpr(path, null, null); + return new NbtExpr(path, null, null, null); + } + + /** + * Create a comparison expression. + */ + public static NbtExpr comparison(NbtPath path, ComparisonOperator operator, NbtValue value) { + return new NbtExpr(path, operator, value, null); + } + + /** + * Create an IN expression. + */ + public static NbtExpr inArray(NbtPath path, NbtArrayLiteral array) { + return new NbtExpr(path, null, null, array); } /** @@ -26,10 +42,21 @@ public boolean hasComparison() { return operator != null && value != null; } + /** + * Check if this expression is an IN check. + */ + public boolean isInExpression() { + return array != null; + } + /** * Convert this expression to its JMESPath representation. */ public String toJmesPath() { + if (isInExpression()) { + // IN expression converts to: contains(array, path) + return "contains(" + array.toJmesPath() + ", " + path.toJmesPath() + ")"; + } if (hasComparison()) { return path.toJmesPath() + " " + toJmesPathOperator(operator) + " " + value.toJmesPath(); } @@ -51,6 +78,9 @@ private static String toJmesPathOperator(ComparisonOperator op) { @Override public String toString() { + if (isInExpression()) { + return path + " IN " + array; + } if (hasComparison()) { return path + " " + operator + " " + value; } diff --git a/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java b/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java index 8630d76b2..80578c935 100644 --- a/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java +++ b/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java @@ -696,7 +696,7 @@ public void nbtExprCompilesToArrayPath() { public void nbtExprCompilesToComparison() { NbtComponent component = NbtComponent.simple("Damage"); NbtPath path = new NbtPath(component, null, List.of()); - NbtExpr expr = new NbtExpr(path, ComparisonOperator.GREATER, new NbtValue.NbtNumber(10)); + NbtExpr expr = NbtExpr.comparison(path, ComparisonOperator.GREATER, new NbtValue.NbtNumber(10)); assertEquals("Damage > `10`", expr.toJmesPath()); } @@ -722,7 +722,7 @@ public void nbtExprCompilesNamespacedWithNestedField() { NbtComponent component = NbtComponent.namespaced("productivebees", "gene_group"); NbtPathElement element = NbtPathElement.field("purity"); NbtPath path = new NbtPath(component, null, List.of(element)); - NbtExpr expr = new NbtExpr(path, ComparisonOperator.EQUALS, new NbtValue.NbtNumber(100)); + NbtExpr expr = NbtExpr.comparison(path, ComparisonOperator.EQUALS, new NbtValue.NbtNumber(100)); assertEquals("\"productivebees:gene_group\".purity == `100`", expr.toJmesPath()); } @@ -752,4 +752,27 @@ public void nbtFilterPathWithoutAt() { NbtFilterPath path = new NbtFilterPath(false, List.of("nested", "field")); assertEquals("nested.field", path.toJmesPath()); } + + @Test + public void nbtInExpressionCompilesToContains() { + NbtComponent component = NbtComponent.namespaced("potion_contents", "potion"); + NbtPath path = new NbtPath(component, null, List.of()); + NbtArrayLiteral array = new NbtArrayLiteral(List.of( + new NbtValue.NbtString("minecraft:water"), + new NbtValue.NbtString("minecraft:mundane") + )); + NbtExpr expr = NbtExpr.inArray(path, array); + + assertEquals("contains(['minecraft:water', 'minecraft:mundane'], \"potion_contents:potion\")", expr.toJmesPath()); + } + + @Test + public void nbtInExpressionParsesCorrectly() { + String input = """ + EVERY 20 TICKS DO + INPUT potion WITH NBT potion_contents.potion IN ["minecraft:water", "minecraft:mundane"] FROM chest + END + """; + assertNoCompileErrors(input); + } } From 48ca09c1b56e7f87cc3365be4db260f70278bf52 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 11:23:09 +0100 Subject: [PATCH 14/20] feat: support wildcard patterns in NBT string comparisons --- .../java/ca/teamdman/sfml/ast/NbtExpr.java | 41 +++++++++++ .../teamdman/sfml/SFMLNbtFilteringTests.java | 69 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/main/java/ca/teamdman/sfml/ast/NbtExpr.java b/src/main/java/ca/teamdman/sfml/ast/NbtExpr.java index de42f08b6..8e064d3a1 100644 --- a/src/main/java/ca/teamdman/sfml/ast/NbtExpr.java +++ b/src/main/java/ca/teamdman/sfml/ast/NbtExpr.java @@ -58,11 +58,52 @@ public String toJmesPath() { return "contains(" + array.toJmesPath() + ", " + path.toJmesPath() + ")"; } if (hasComparison()) { + // Check for wildcard pattern in string equality comparisons + if (operator == ComparisonOperator.EQUALS && value instanceof NbtValue.NbtString strVal) { + String pattern = strVal.value(); + if (pattern.contains("*")) { + return wildcardToJmesPath(path.toJmesPath(), pattern); + } + } return path.toJmesPath() + " " + toJmesPathOperator(operator) + " " + value.toJmesPath(); } return path.toJmesPath(); } + /** + * Convert a wildcard pattern to JMESPath function call. + * - "prefix*" -> starts_with(path, 'prefix') + * - "*suffix" -> ends_with(path, 'suffix') + * - "*contains*" -> contains(path, 'contains') + * - "*" -> path (existence check) + */ + private String wildcardToJmesPath(String pathExpr, String pattern) { + boolean startsWithStar = pattern.startsWith("*"); + boolean endsWithStar = pattern.endsWith("*"); + String content = pattern.replace("*", ""); + String escaped = content.replace("'", "\\'"); + + if (content.isEmpty()) { + // Just "*" means match anything (existence check) + return pathExpr; + } + + if (startsWithStar && endsWithStar) { + // *contains* -> contains(path, 'contains') + return "contains(" + pathExpr + ", '" + escaped + "')"; + } else if (startsWithStar) { + // *suffix -> ends_with(path, 'suffix') + return "ends_with(" + pathExpr + ", '" + escaped + "')"; + } else if (endsWithStar) { + // prefix* -> starts_with(path, 'prefix') + return "starts_with(" + pathExpr + ", '" + escaped + "')"; + } else { + // No wildcards at edges but contains * in middle - treat as contains + // e.g., "mine*craft" - just check contains for simplicity + return "contains(" + pathExpr + ", '" + escaped + "')"; + } + } + /** * Convert comparison operator to JMESPath syntax. */ diff --git a/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java b/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java index 80578c935..682db4f4d 100644 --- a/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java +++ b/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java @@ -775,4 +775,73 @@ public void nbtInExpressionParsesCorrectly() { """; assertNoCompileErrors(input); } + + // ==================== Wildcard Pattern Tests ==================== + + @Test + public void nbtWildcardStartsWith() { + NbtComponent component = NbtComponent.simple("id"); + NbtPath path = new NbtPath(component, null, List.of()); + NbtExpr expr = NbtExpr.comparison(path, ComparisonOperator.EQUALS, new NbtValue.NbtString("mekanism:*")); + + assertEquals("starts_with(id, 'mekanism:')", expr.toJmesPath()); + } + + @Test + public void nbtWildcardEndsWith() { + NbtComponent component = NbtComponent.simple("id"); + NbtPath path = new NbtPath(component, null, List.of()); + NbtExpr expr = NbtExpr.comparison(path, ComparisonOperator.EQUALS, new NbtValue.NbtString("*_ore")); + + assertEquals("ends_with(id, '_ore')", expr.toJmesPath()); + } + + @Test + public void nbtWildcardContains() { + NbtComponent component = NbtComponent.simple("id"); + NbtPath path = new NbtPath(component, null, List.of()); + NbtExpr expr = NbtExpr.comparison(path, ComparisonOperator.EQUALS, new NbtValue.NbtString("*diamond*")); + + assertEquals("contains(id, 'diamond')", expr.toJmesPath()); + } + + @Test + public void nbtWildcardAny() { + NbtComponent component = NbtComponent.simple("id"); + NbtPath path = new NbtPath(component, null, List.of()); + NbtExpr expr = NbtExpr.comparison(path, ComparisonOperator.EQUALS, new NbtValue.NbtString("*")); + + // Just "*" means existence check + assertEquals("id", expr.toJmesPath()); + } + + @Test + public void nbtWildcardParsesCorrectly() { + String input = """ + EVERY 20 TICKS DO + INPUT WITH NBT id = "mekanism:*" FROM chest + END + """; + assertNoCompileErrors(input); + } + + @Test + public void nbtWildcardEndsWithParsesCorrectly() { + String input = """ + EVERY 20 TICKS DO + INPUT WITH NBT id = "*_ore" FROM chest + END + """; + assertNoCompileErrors(input); + } + + @Test + public void nbtWildcardContainsParsesCorrectly() { + String input = """ + EVERY 20 TICKS DO + INPUT WITH NBT id = "*diamond*" FROM chest + END + """; + assertNoCompileErrors(input); + } } From 8485731fdc98e5fb915f3b4a1fef39afbcb5389e Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 11:39:39 +0100 Subject: [PATCH 15/20] build: make jarJar the default output, rename slim jar --- build.gradle | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build.gradle b/build.gradle index afaa6c7b6..60ea7b8fd 100644 --- a/build.gradle +++ b/build.gradle @@ -522,6 +522,14 @@ jar.finalizedBy('reobfJar') // Enable jarJar for bundling JMESPath library jarJar.enable() +// Use jarJar as the main output (remove -all suffix) +tasks.named('jarJar') { + archiveClassifier = '' +} +tasks.named('jar') { + archiveClassifier = 'slim' +} + publishing { publications { mavenJava(MavenPublication) { From e937086bac3b6d46984dd30a56456b019a25601f Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 12:38:44 +0100 Subject: [PATCH 16/20] fix: support wildcard patterns in NBT array filter expressions --- .../ca/teamdman/sfml/ast/NbtFilterExpr.java | 40 +++++++++++++++++ .../teamdman/sfml/SFMLNbtFilteringTests.java | 43 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/main/java/ca/teamdman/sfml/ast/NbtFilterExpr.java b/src/main/java/ca/teamdman/sfml/ast/NbtFilterExpr.java index bd229a9d3..bc3e6f4ef 100644 --- a/src/main/java/ca/teamdman/sfml/ast/NbtFilterExpr.java +++ b/src/main/java/ca/teamdman/sfml/ast/NbtFilterExpr.java @@ -24,11 +24,51 @@ public boolean hasComparison() { */ public String toJmesPath() { if (hasComparison()) { + // Check for wildcard pattern in string equality comparisons + if (operator == ComparisonOperator.EQUALS && value instanceof NbtValue.NbtString strVal) { + String pattern = strVal.value(); + if (pattern.contains("*")) { + return wildcardToJmesPath(path.toJmesPath(), pattern); + } + } return path.toJmesPath() + " " + toJmesPathOperator(operator) + " " + value.toJmesPath(); } return path.toJmesPath(); } + /** + * Convert a wildcard pattern to JMESPath function call. + * - "prefix*" -> starts_with(path, 'prefix') + * - "*suffix" -> ends_with(path, 'suffix') + * - "*contains*" -> contains(path, 'contains') + * - "*" -> path (existence check) + */ + private String wildcardToJmesPath(String pathExpr, String pattern) { + boolean startsWithStar = pattern.startsWith("*"); + boolean endsWithStar = pattern.endsWith("*"); + String content = pattern.replace("*", ""); + String escaped = content.replace("'", "\\'"); + + if (content.isEmpty()) { + // Just "*" means match anything (existence check) + return pathExpr; + } + + if (startsWithStar && endsWithStar) { + // *contains* -> contains(path, 'contains') + return "contains(" + pathExpr + ", '" + escaped + "')"; + } else if (startsWithStar) { + // *suffix -> ends_with(path, 'suffix') + return "ends_with(" + pathExpr + ", '" + escaped + "')"; + } else if (endsWithStar) { + // prefix* -> starts_with(path, 'prefix') + return "starts_with(" + pathExpr + ", '" + escaped + "')"; + } else { + // No wildcards at edges but contains * in middle - treat as contains + return "contains(" + pathExpr + ", '" + escaped + "')"; + } + } + /** * Convert comparison operator to JMESPath syntax. */ diff --git a/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java b/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java index 682db4f4d..d286d0500 100644 --- a/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java +++ b/src/test/java/ca/teamdman/sfml/SFMLNbtFilteringTests.java @@ -844,4 +844,47 @@ public void nbtWildcardContainsParsesCorrectly() { """; assertNoCompileErrors(input); } + + @Test + public void nbtWildcardInArrayFilter() { + String input = """ + EVERY 20 TICKS DO + INPUT WITH NBT Enchantments[?id = "minecraft:*"] FROM chest + END + """; + assertNoCompileErrors(input); + } + + @Test + public void nbtWildcardInArrayFilterConvertsCorrectly() { + NbtFilterPath path = new NbtFilterPath(false, List.of("id")); + NbtFilterExpr expr = new NbtFilterExpr(path, ComparisonOperator.EQUALS, new NbtValue.NbtString("minecraft:*")); + + assertEquals("starts_with(id, 'minecraft:')", expr.toJmesPath()); + } + + @Test + public void nbtWildcardEndsWithInArrayFilter() { + NbtFilterPath path = new NbtFilterPath(false, List.of("id")); + NbtFilterExpr expr = new NbtFilterExpr(path, ComparisonOperator.EQUALS, new NbtValue.NbtString("*_protection")); + + assertEquals("ends_with(id, '_protection')", expr.toJmesPath()); + } + + @Test + public void nbtWildcardContainsInArrayFilter() { + NbtFilterPath path = new NbtFilterPath(false, List.of("id")); + NbtFilterExpr expr = new NbtFilterExpr(path, ComparisonOperator.EQUALS, new NbtValue.NbtString("*fire*")); + + assertEquals("contains(id, 'fire')", expr.toJmesPath()); + } + + @Test + public void nbtWildcardAnyInArrayFilter() { + NbtFilterPath path = new NbtFilterPath(false, List.of("id")); + NbtFilterExpr expr = new NbtFilterExpr(path, ComparisonOperator.EQUALS, new NbtValue.NbtString("*")); + + // Just "*" means existence check + assertEquals("id", expr.toJmesPath()); + } } From d61f189c44c8256d2fdce4be92dd3f1775476b48 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 13:11:28 +0100 Subject: [PATCH 17/20] feat: log JMESPath conversion for NBT expressions --- src/main/java/ca/teamdman/sfml/ast/WithNbt.java | 2 ++ src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/ca/teamdman/sfml/ast/WithNbt.java b/src/main/java/ca/teamdman/sfml/ast/WithNbt.java index 5e78e5ed1..6b7a131e7 100644 --- a/src/main/java/ca/teamdman/sfml/ast/WithNbt.java +++ b/src/main/java/ca/teamdman/sfml/ast/WithNbt.java @@ -1,5 +1,6 @@ package ca.teamdman.sfml.ast; +import ca.teamdman.sfm.SFM; import ca.teamdman.sfm.common.resourcetype.ResourceType; import ca.teamdman.sfm.common.util.NbtJmesPathEvaluator; @@ -20,6 +21,7 @@ public record WithNbt( * @throws io.burt.jmespath.parser.ParseException if the expression is invalid */ public static WithNbt create(String jmesPathExpression) { + SFM.LOGGER.debug("NBT quoted JMESPath expression \"{}\"", jmesPathExpression); NbtJmesPathEvaluator evaluator = NbtJmesPathEvaluator.compile(jmesPathExpression); return new WithNbt(jmesPathExpression, evaluator); } diff --git a/src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java b/src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java index c35f6557c..d18151858 100644 --- a/src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java +++ b/src/main/java/ca/teamdman/sfml/ast/WithNbtExpr.java @@ -1,5 +1,6 @@ package ca.teamdman.sfml.ast; +import ca.teamdman.sfm.SFM; import ca.teamdman.sfm.common.resourcetype.ResourceType; import ca.teamdman.sfm.common.util.NbtJmesPathEvaluator; @@ -24,6 +25,7 @@ public record WithNbtExpr( */ public static WithNbtExpr create(NbtExpr expression) { String jmesPath = expression.toJmesPath(); + SFM.LOGGER.debug("NBT expression \"{}\" compiled to JMESPath \"{}\"", expression, jmesPath); NbtJmesPathEvaluator evaluator = NbtJmesPathEvaluator.compile(jmesPath); return new WithNbtExpr(expression, jmesPath, evaluator); } @@ -38,6 +40,6 @@ public boolean matchesStack( @Override public String toString() { - return "NBT " + expression; + return "NBT " + expression + " (JMESPath: " + jmesPathExpression + ")"; } } From 1dc01305ee59c6f3dc39af081f4b8b79bc815c3b Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sat, 24 Jan 2026 13:13:57 +0100 Subject: [PATCH 18/20] docs: add NBT matching template program --- .../sfm/template_programs/nbt_matching.sfml | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 src/main/resources/assets/sfm/template_programs/nbt_matching.sfml diff --git a/src/main/resources/assets/sfm/template_programs/nbt_matching.sfml b/src/main/resources/assets/sfm/template_programs/nbt_matching.sfml new file mode 100644 index 000000000..d55ad54b6 --- /dev/null +++ b/src/main/resources/assets/sfm/template_programs/nbt_matching.sfml @@ -0,0 +1,206 @@ +NAME "NBT Matching" + +-- ============================================ +-- SECTION 1: Basic NBT Existence Checks +-- ============================================ +-- NBT tags only exist when items have relevant data +-- For example, Enchantments tag only exists on enchanted items + +EVERY 20 TICKS DO + -- items that have enchantments (tag exists) + INPUT WITH NBT Enchantments FROM chest + OUTPUT TO enchanted +END + +EVERY 20 TICKS DO + -- items without enchantments (tag doesn't exist) + INPUT WITHOUT NBT Enchantments FROM chest + OUTPUT TO plain_items +END + +-- ============================================ +-- SECTION 2: Comparison Operators +-- ============================================ +-- Filter by numeric NBT values +EVERY 20 TICKS DO + -- damaged items (Damage > 0) + INPUT WITH NBT Damage > 0 FROM tools + OUTPUT TO repair_chest +END + +EVERY 20 TICKS DO + -- nearly broken items (high damage) + INPUT WITH NBT Damage >= 200 FROM tools + OUTPUT TO urgent_repair +END + +EVERY 20 TICKS DO + -- pristine items only (no damage) + INPUT WITH NBT Damage = 0 FROM tools + OUTPUT TO storage +END + +-- ============================================ +-- SECTION 3: Wildcard String Matching +-- ============================================ +-- Match NBT string values with wildcards +-- Uses: prefix*, *suffix, *contains*, * + +EVERY 20 TICKS DO + -- prefix match: enchantments from a mod + INPUT WITH NBT Enchantments[?id = "minecraft:*"] FROM chest + OUTPUT TO vanilla_enchants +END + +EVERY 20 TICKS DO + -- suffix match: protection enchantments + INPUT WITH NBT Enchantments[?id = "*protection"] FROM chest + OUTPUT TO protection_gear +END + +EVERY 20 TICKS DO + -- contains match: anything "fire" related + INPUT WITH NBT Enchantments[?id = "*fire*"] FROM chest + OUTPUT TO fire_items +END + +EVERY 20 TICKS DO + -- existence check: has any id field + INPUT WITH NBT Enchantments[?id = "*"] FROM chest + OUTPUT TO has_enchant_id +END + +EVERY 20 TICKS DO + -- filter speed potions + INPUT potion WITH NBT Potion = "*strong*" FROM potions + OUTPUT TO strong_potions +END + +-- ============================================ +-- SECTION 4: IN Operator +-- ============================================ +-- Check if value is one of several options + +EVERY 20 TICKS DO + -- filter basic/water potions (1.19.2 uses Potion tag) + INPUT potion WITH NBT Potion IN [ + "minecraft:water", + "minecraft:mundane", + "minecraft:thick", + "minecraft:awkward" + ] FROM potions + OUTPUT TO basic_potions +END + +EVERY 20 TICKS DO + -- filter healing/regeneration potions + INPUT potion WITH NBT Potion IN [ + "minecraft:healing", + "minecraft:strong_healing", + "minecraft:regeneration", + "minecraft:strong_regeneration" + ] FROM potions + OUTPUT TO medical_potions +END + +-- ============================================ +-- SECTION 5: Array Operations +-- ============================================ +-- Work with NBT arrays like enchantments + +EVERY 20 TICKS DO + -- items with any enchantment + INPUT WITH NBT Enchantments[0] FROM chest + OUTPUT TO enchanted +END + +EVERY 20 TICKS DO + -- items with specific enchantment + INPUT WITH NBT Enchantments[?id = "minecraft:sharpness"] FROM chest + OUTPUT TO sharpness_items +END + +EVERY 20 TICKS DO + -- high-level enchantments only (level 4+) + INPUT WITH NBT Enchantments[?lvl >= 4] FROM chest + OUTPUT TO high_level +END + +EVERY 20 TICKS DO + -- specific enchantment at specific level (use quoted JMESPath for compound filters) + INPUT WITH NBT "Enchantments[?id == 'minecraft:fortune' && lvl == `3`]" FROM chest + OUTPUT TO fortune3 +END + +-- ============================================ +-- SECTION 6: Combining with Tags +-- ============================================ +-- Mix NBT matching with tag matching + +EVERY 20 TICKS DO + -- damaged swords only + INPUT WITH NBT Damage > 0 AND TAG forge:tools/swords FROM tools + OUTPUT TO sword_repair +END + +EVERY 20 TICKS DO + -- enchanted pickaxes + INPUT WITH NBT Enchantments[0] AND TAG forge:tools/pickaxes FROM tools + OUTPUT TO enchanted_picks +END + +EVERY 20 TICKS DO + -- tools that are either damaged OR enchanted + INPUT WITH TAG forge:tools AND (NBT Damage > 0 OR NBT Enchantments[0]) FROM chest + OUTPUT TO special_tools +END + +-- ============================================ +-- SECTION 7: Real-World Use Cases +-- ============================================ + +-- TOOL SORTING: Sort tools by condition +EVERY 20 TICKS DO + -- pristine tools to main storage + INPUT WITH NBT Damage = 0 AND TAG forge:tools FROM unsorted + OUTPUT TO pristine_tools +END + +EVERY 20 TICKS DO + -- damaged tools to repair station + INPUT WITH NBT Damage > 0 AND TAG forge:tools FROM unsorted + OUTPUT TO repair_station +END + +-- ENCHANTMENT SORTING: Group by enchantment type +EVERY 20 TICKS DO + -- silk touch items + INPUT WITH NBT Enchantments[?id = "minecraft:silk_touch"] FROM unsorted + OUTPUT TO silk_touch_tools +END + +EVERY 20 TICKS DO + -- fortune items + INPUT WITH NBT Enchantments[?id = "minecraft:fortune"] FROM unsorted + OUTPUT TO fortune_tools +END + +-- MOD INTEGRATION: ProductiveBees example +EVERY 20 TICKS DO + -- filter bees by purity level + INPUT WITH NBT productivebees:gene_group.purity >= 90 FROM hives + OUTPUT TO high_purity_bees +END + +-- MOD INTEGRATION: Filter enchantments by mod +EVERY 20 TICKS DO + -- apotheosis enchantments (prefix match) + INPUT WITH NBT Enchantments[?id = "apotheosis:*"] FROM chest + OUTPUT TO apotheosis_items +END + +EVERY 20 TICKS DO + -- ensorcellation enchantments + INPUT WITH NBT Enchantments[?id = "ensorcellation:*"] FROM chest + OUTPUT TO ensorcellation_items +END From 80dfecbbe1ff8654b65af5d666da74c17f8e3bdd Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sun, 25 Jan 2026 23:12:55 +0100 Subject: [PATCH 19/20] fix: nbt game tests to use new expression syntax --- .../nbt_filtering/NbtFilterCombinedWithTagGameTest.java | 6 +++--- .../tests/nbt_filtering/NbtFilterDamagedItemsGameTest.java | 6 +++--- .../nbt_filtering/NbtFilterEnchantedItemsGameTest.java | 6 +++--- .../nbt_filtering/NbtFilterWithoutEnchantmentsGameTest.java | 6 +++--- src/main/antlr/sfml/SFML.g4 | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterCombinedWithTagGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterCombinedWithTagGameTest.java index a30ea8edd..8a00a1bae 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterCombinedWithTagGameTest.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterCombinedWithTagGameTest.java @@ -36,7 +36,7 @@ public void run(SFMGameTestHelper helper) { test.setProgram(""" EVERY 20 TICKS DO -- Only move damaged items that are also swords (have the sword tag) - INPUT WITH NBT "Damage > `0`" AND #minecraft:swords FROM left + INPUT WITH NBT Damage > 0 AND TAG forge:tools/swords FROM left OUTPUT TO right END """); @@ -51,10 +51,10 @@ public void run(SFMGameTestHelper helper) { // The damaged pickaxe stays because it doesn't have the sword tag test.postContents("left", Arrays.asList( ItemStack.EMPTY, - damagedPickaxe + damagedPickaxe.copy() )); test.postContents("right", Arrays.asList( - damagedSword + damagedSword.copy() )); test.run(); diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterDamagedItemsGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterDamagedItemsGameTest.java index 79ff4559f..7d1499bfa 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterDamagedItemsGameTest.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterDamagedItemsGameTest.java @@ -34,7 +34,7 @@ public void run(SFMGameTestHelper helper) { test.setProgram(""" EVERY 20 TICKS DO -- Only move items with Damage > 0 - INPUT WITH NBT "Damage > `0`" FROM left + INPUT WITH NBT Damage > 0 FROM left OUTPUT TO right END """); @@ -49,10 +49,10 @@ public void run(SFMGameTestHelper helper) { // The undamaged sword stays in the left test.postContents("left", Arrays.asList( ItemStack.EMPTY, - undamagedSword + undamagedSword.copy() )); test.postContents("right", Arrays.asList( - damagedSword + damagedSword.copy() )); test.run(); diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterEnchantedItemsGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterEnchantedItemsGameTest.java index 83abaa3b8..b1ee02f95 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterEnchantedItemsGameTest.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterEnchantedItemsGameTest.java @@ -34,7 +34,7 @@ public void run(SFMGameTestHelper helper) { test.setProgram(""" EVERY 20 TICKS DO -- Only move items with enchantments (Enchantments array is non-empty) - INPUT WITH NBT "Enchantments[0]" FROM left + INPUT WITH NBT Enchantments[0] FROM left OUTPUT TO right END """); @@ -48,10 +48,10 @@ public void run(SFMGameTestHelper helper) { // Only the enchanted sword should move to the right test.postContents("left", Arrays.asList( ItemStack.EMPTY, - plainSword + plainSword.copy() )); test.postContents("right", Arrays.asList( - enchantedSword + enchantedSword.copy() )); test.run(); diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterWithoutEnchantmentsGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterWithoutEnchantmentsGameTest.java index 283b1e57a..58bcd250c 100644 --- a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterWithoutEnchantmentsGameTest.java +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterWithoutEnchantmentsGameTest.java @@ -34,7 +34,7 @@ public void run(SFMGameTestHelper helper) { test.setProgram(""" EVERY 20 TICKS DO -- Only move items WITHOUT enchantments - INPUT WITHOUT NBT "Enchantments[0]" FROM left + INPUT WITHOUT NBT Enchantments[0] FROM left OUTPUT TO right END """); @@ -47,11 +47,11 @@ public void run(SFMGameTestHelper helper) { // Only the plain sword should move to the right test.postContents("left", Arrays.asList( - enchantedSword, + enchantedSword.copy(), ItemStack.EMPTY )); test.postContents("right", Arrays.asList( - plainSword + plainSword.copy() )); test.run(); diff --git a/src/main/antlr/sfml/SFML.g4 b/src/main/antlr/sfml/SFML.g4 index 2c6698861..60c0d4a4f 100644 --- a/src/main/antlr/sfml/SFML.g4 +++ b/src/main/antlr/sfml/SFML.g4 @@ -45,8 +45,8 @@ inputResourceLimits : resourceLimitList; // separate for different defaults outputResourceLimits : resourceLimitList; // separate for different defaults resourceLimitList : resourceLimit (COMMA resourceLimit)* COMMA?; -resourceLimit : limit? resourceIdDisjunction with? - | limit with? +resourceLimit : limit with? + | limit? resourceIdDisjunction with? | with ; limit : quantity retention #QuantityRetentionLimit From 2780a77acf8058abe895e7c84426b8f14cf7a2f4 Mon Sep 17 00:00:00 2001 From: Reinder Noordmans Date: Sun, 25 Jan 2026 23:30:57 +0100 Subject: [PATCH 20/20] test: add NBT IN array filter test for potions --- .../NbtFilterPotionInArrayGameTest.java | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterPotionInArrayGameTest.java diff --git a/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterPotionInArrayGameTest.java b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterPotionInArrayGameTest.java new file mode 100644 index 000000000..4ec68e56b --- /dev/null +++ b/src/gametest/java/ca/teamdman/sfm/gametest/tests/nbt_filtering/NbtFilterPotionInArrayGameTest.java @@ -0,0 +1,79 @@ +package ca.teamdman.sfm.gametest.tests.nbt_filtering; + +import ca.teamdman.sfm.gametest.LeftRightManagerTest; +import ca.teamdman.sfm.gametest.SFMGameTest; +import ca.teamdman.sfm.gametest.SFMGameTestDefinition; +import ca.teamdman.sfm.gametest.SFMGameTestHelper; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.alchemy.PotionUtils; +import net.minecraft.world.item.alchemy.Potions; + +import java.util.Arrays; + +/** + * Tests that NBT IN array filtering can exclude basic potions. + * Move only non-basic potions (not water, mundane, awkward, or thick). + */ +@SFMGameTest +public class NbtFilterPotionInArrayGameTest extends SFMGameTestDefinition { + @Override + public String template() { + return "3x2x1"; + } + + @Override + public void run(SFMGameTestHelper helper) { + var test = new LeftRightManagerTest(helper); + + // Create basic potions (should NOT be moved) + ItemStack waterPotion = PotionUtils.setPotion(new ItemStack(Items.POTION), Potions.WATER); + ItemStack mundanePotion = PotionUtils.setPotion(new ItemStack(Items.POTION), Potions.MUNDANE); + ItemStack awkwardPotion = PotionUtils.setPotion(new ItemStack(Items.POTION), Potions.AWKWARD); + ItemStack thickPotion = PotionUtils.setPotion(new ItemStack(Items.POTION), Potions.THICK); + + // Create non-basic potions (should be moved) + ItemStack healingPotion = PotionUtils.setPotion(new ItemStack(Items.POTION), Potions.HEALING); + ItemStack strengthPotion = PotionUtils.setPotion(new ItemStack(Items.POTION), Potions.STRENGTH); + + test.setProgram(""" + EVERY 20 TICKS DO + -- Only move potions that are NOT basic (water, mundane, awkward, thick) + INPUT WITHOUT NBT Potion IN [ + "minecraft:water", + "minecraft:mundane", + "minecraft:awkward", + "minecraft:thick" + ] FROM left + OUTPUT TO right + END + """); + + // Put all potions in the left chest + test.preContents("left", Arrays.asList( + waterPotion, + mundanePotion, + awkwardPotion, + thickPotion, + healingPotion, + strengthPotion + )); + + // Only healing and strength potions should move to the right + // Basic potions stay in the left + test.postContents("left", Arrays.asList( + waterPotion.copy(), + mundanePotion.copy(), + awkwardPotion.copy(), + thickPotion.copy(), + ItemStack.EMPTY, + ItemStack.EMPTY + )); + test.postContents("right", Arrays.asList( + healingPotion.copy(), + strengthPotion.copy() + )); + + test.run(); + } +}