From 3fc4752e4f738cb9bae1749e08f5c4f27be76ada Mon Sep 17 00:00:00 2001
From: apple502j <33279053+apple502j@users.noreply.github.com>
Date: Thu, 5 Jan 2023 21:49:30 +0900
Subject: [PATCH] Entity Selector Option API (#2667)

* Entity Selector Option API

* Fix compile error

* Add default impl

* Apply suggestions from code review

Co-authored-by: Technici4n <13494793+Technici4n@users.noreply.github.com>

Co-authored-by: modmuss50 <modmuss50@gmail.com>
Co-authored-by: Technici4n <13494793+Technici4n@users.noreply.github.com>
---
 .../v2/EntitySelectorOptionRegistry.java      | 84 +++++++++++++++++++
 .../v2/FabricEntitySelectorReader.java        | 45 ++++++++++
 .../EntitySelectorOptionsAccessor.java        | 33 ++++++++
 .../command/EntitySelectorReaderMixin.java    | 48 +++++++++++
 .../fabric-command-api-v2.mixins.json         |  2 +
 .../src/main/resources/fabric.mod.json        |  5 +-
 .../fabric/test/command/CommandTest.java      | 18 +++-
 .../test/command/EntitySelectorGameTest.java  | 61 ++++++++++++++
 .../src/testmod/resources/fabric.mod.json     |  3 +
 9 files changed, 297 insertions(+), 2 deletions(-)
 create mode 100644 fabric-command-api-v2/src/main/java/net/fabricmc/fabric/api/command/v2/EntitySelectorOptionRegistry.java
 create mode 100644 fabric-command-api-v2/src/main/java/net/fabricmc/fabric/api/command/v2/FabricEntitySelectorReader.java
 create mode 100644 fabric-command-api-v2/src/main/java/net/fabricmc/fabric/mixin/command/EntitySelectorOptionsAccessor.java
 create mode 100644 fabric-command-api-v2/src/main/java/net/fabricmc/fabric/mixin/command/EntitySelectorReaderMixin.java
 create mode 100644 fabric-command-api-v2/src/testmod/java/net/fabricmc/fabric/test/command/EntitySelectorGameTest.java

diff --git a/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/api/command/v2/EntitySelectorOptionRegistry.java b/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/api/command/v2/EntitySelectorOptionRegistry.java
new file mode 100644
index 000000000..01caa7e47
--- /dev/null
+++ b/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/api/command/v2/EntitySelectorOptionRegistry.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.command.v2;
+
+import java.util.function.Predicate;
+
+import net.minecraft.command.EntitySelectorOptions;
+import net.minecraft.command.EntitySelectorReader;
+import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
+
+import net.fabricmc.fabric.mixin.command.EntitySelectorOptionsAccessor;
+
+/**
+ * Contains a function to register an entity selector option.
+ */
+public final class EntitySelectorOptionRegistry {
+	private EntitySelectorOptionRegistry() {
+	}
+
+	/**
+	 * Registers an entity selector option. The added option is available under the underscore
+	 * separated ID.
+	 *
+	 * <p>Here's an example of a custom entity selector option. The option is registered under
+	 * {@code example_min_health} and can be used like {@code @e[example_min_health=5]}.
+	 * <pre>{@code
+	 * EntitySelectorOptionRegistry.register(
+	 * 	new Identifier("example", "min_health"),
+	 * 	Text.literal("Minimum entity health"),
+	 * 	(reader) -> {
+	 * 	    final float minHealth = reader.getReader().readFloat();
+	 *
+	 * 	    if (minHealth > 0) {
+	 * 	        reader.setPredicate((entity) -> entity instanceof LivingEntity livingEntity && livingEntity.getHealth() >= minHealth);
+	 * 	    }
+	 * 	},
+	 * 	(reader) -> true
+	 * );
+	 * }</pre>
+	 *
+	 * <p>By default, a selector option can be used multiple times. To make a non-repeatable
+	 * option, either use {@link FabricEntitySelectorReader} to flag the existence of an option
+	 * and check it inside {@code canUse}, or use {@link #registerNonRepeatable} instead of this
+	 * method.
+	 *
+	 * @param id the ID of the option
+	 * @param description the description of the option
+	 * @param handler the handler for the entity option that reads and sets the predicate
+	 * @param canUse the predicate that checks whether the option is syntactically valid
+	 */
+	public static void register(Identifier id, Text description, EntitySelectorOptions.SelectorHandler handler, Predicate<EntitySelectorReader> canUse) {
+		EntitySelectorOptionsAccessor.callPutOption(id.toUnderscoreSeparatedString(), handler, canUse, description);
+	}
+
+	/**
+	 * Registers an entity selector option. The added option is available under the underscore
+	 * separated ID. The added option cannot be used multiple times within a single selector.
+	 *
+	 * @param id the ID of the option
+	 * @param description the description of the option
+	 * @param handler the handler for the entity option that reads and sets the predicate
+	 */
+	public static void registerNonRepeatable(Identifier id, Text description, EntitySelectorOptions.SelectorHandler handler) {
+		register(id, description, (reader) -> {
+			handler.handle(reader);
+			reader.setCustomFlag(id, true);
+		}, (reader) -> !reader.getCustomFlag(id)); // has a flag = used before
+	}
+}
diff --git a/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/api/command/v2/FabricEntitySelectorReader.java b/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/api/command/v2/FabricEntitySelectorReader.java
new file mode 100644
index 000000000..1462c1a47
--- /dev/null
+++ b/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/api/command/v2/FabricEntitySelectorReader.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.command.v2;
+
+import net.minecraft.util.Identifier;
+
+/**
+ * Fabric extension to {@link net.minecraft.command.EntitySelectorReader}, implemented
+ * using interface injection. This allows custom entity selectors to
+ * set a custom flag to a reader. This can be used to implement mutually-exclusive
+ * or non-repeatable entity selector option.
+ */
+public interface FabricEntitySelectorReader {
+	/**
+	 * Sets a flag.
+	 * @param key the key of the flag
+	 * @param value the value of the flag
+	 */
+	default void setCustomFlag(Identifier key, boolean value) {
+		throw new UnsupportedOperationException("Implemented via mixin");
+	}
+
+	/**
+	 * Gets the value of the flag.
+	 * @param key the key of the flag
+	 * @return the value, or {@code false} if the flag is not set
+	 */
+	default boolean getCustomFlag(Identifier key) {
+		throw new UnsupportedOperationException("Implemented via mixin");
+	}
+}
diff --git a/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/mixin/command/EntitySelectorOptionsAccessor.java b/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/mixin/command/EntitySelectorOptionsAccessor.java
new file mode 100644
index 000000000..ccaee74a2
--- /dev/null
+++ b/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/mixin/command/EntitySelectorOptionsAccessor.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.command;
+
+import java.util.function.Predicate;
+
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Invoker;
+
+import net.minecraft.command.EntitySelectorOptions;
+import net.minecraft.command.EntitySelectorReader;
+import net.minecraft.text.Text;
+
+@Mixin(EntitySelectorOptions.class)
+public interface EntitySelectorOptionsAccessor {
+	@Invoker
+	static void callPutOption(String id, EntitySelectorOptions.SelectorHandler handler, Predicate<EntitySelectorReader> condition, Text description) {
+	}
+}
diff --git a/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/mixin/command/EntitySelectorReaderMixin.java b/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/mixin/command/EntitySelectorReaderMixin.java
new file mode 100644
index 000000000..3cb8f8ed9
--- /dev/null
+++ b/fabric-command-api-v2/src/main/java/net/fabricmc/fabric/mixin/command/EntitySelectorReaderMixin.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.command;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Unique;
+
+import net.minecraft.command.EntitySelectorReader;
+import net.minecraft.util.Identifier;
+
+import net.fabricmc.fabric.api.command.v2.FabricEntitySelectorReader;
+
+@Mixin(EntitySelectorReader.class)
+public class EntitySelectorReaderMixin implements FabricEntitySelectorReader {
+	@Unique
+	private final Set<Identifier> flags = new HashSet<>();
+
+	@Override
+	public void setCustomFlag(Identifier key, boolean value) {
+		if (value) {
+			this.flags.add(key);
+		} else {
+			this.flags.remove(key);
+		}
+	}
+
+	@Override
+	public boolean getCustomFlag(Identifier key) {
+		return this.flags.contains(key);
+	}
+}
diff --git a/fabric-command-api-v2/src/main/resources/fabric-command-api-v2.mixins.json b/fabric-command-api-v2/src/main/resources/fabric-command-api-v2.mixins.json
index f011f37c1..0117a3d14 100644
--- a/fabric-command-api-v2/src/main/resources/fabric-command-api-v2.mixins.json
+++ b/fabric-command-api-v2/src/main/resources/fabric-command-api-v2.mixins.json
@@ -5,6 +5,8 @@
   "mixins": [
     "ArgumentTypesAccessor",
     "CommandManagerMixin",
+    "EntitySelectorOptionsAccessor",
+    "EntitySelectorReaderMixin",
     "HelpCommandAccessor"
   ],
   "injectors": {
diff --git a/fabric-command-api-v2/src/main/resources/fabric.mod.json b/fabric-command-api-v2/src/main/resources/fabric.mod.json
index 8c2eaac2a..fdda5fe2a 100644
--- a/fabric-command-api-v2/src/main/resources/fabric.mod.json
+++ b/fabric-command-api-v2/src/main/resources/fabric.mod.json
@@ -30,6 +30,9 @@
     }
   ],
   "custom": {
-    "fabric-api:module-lifecycle": "stable"
+    "fabric-api:module-lifecycle": "stable",
+    "loom:injected_interfaces": {
+      "net/minecraft/class_2303": ["net/fabricmc/fabric/api/command/v2/FabricEntitySelectorReader"]
+    }
   }
 }
diff --git a/fabric-command-api-v2/src/testmod/java/net/fabricmc/fabric/test/command/CommandTest.java b/fabric-command-api-v2/src/testmod/java/net/fabricmc/fabric/test/command/CommandTest.java
index ced6318bc..748ad298c 100644
--- a/fabric-command-api-v2/src/testmod/java/net/fabricmc/fabric/test/command/CommandTest.java
+++ b/fabric-command-api-v2/src/testmod/java/net/fabricmc/fabric/test/command/CommandTest.java
@@ -26,15 +26,19 @@ import com.mojang.brigadier.tree.RootCommandNode;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import net.minecraft.entity.LivingEntity;
 import net.minecraft.server.command.ServerCommandSource;
 import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
 
 import net.fabricmc.api.ModInitializer;
 import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
+import net.fabricmc.fabric.api.command.v2.EntitySelectorOptionRegistry;
 import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
 
 public final class CommandTest implements ModInitializer {
 	private static final Logger LOGGER = LoggerFactory.getLogger(CommandTest.class);
+	static final Identifier SELECTOR_ID = new Identifier("fabric-command-api-v2-testmod", "min_health");
 	private static final SimpleCommandExceptionType WRONG_SIDE_SHOULD_BE_INTEGRATED = new SimpleCommandExceptionType(Text.literal("This command was registered incorrectly. Should only be present on an integrated server but was ran on a dedicated server!"));
 	private static final SimpleCommandExceptionType WRONG_SIDE_SHOULD_BE_DEDICATED = new SimpleCommandExceptionType(Text.literal("This command was registered incorrectly. Should only be present on an dedicated server but was ran on an integrated server!"));
 
@@ -50,7 +54,7 @@ public final class CommandTest implements ModInitializer {
 			}
 
 			if (environment.integrated) {
-				// The command here should only be present on a integrated server
+				// The command here should only be present on an integrated server
 				dispatcher.register(literal("fabric_integrated_test_command").executes(this::executeIntegratedCommand));
 			}
 		});
@@ -95,6 +99,18 @@ public final class CommandTest implements ModInitializer {
 			// Success!
 			CommandTest.LOGGER.info("The command tests have passed! Please make sure you execute the three commands for extra safety.");
 		});
+
+		EntitySelectorOptionRegistry.registerNonRepeatable(
+				SELECTOR_ID,
+				Text.literal("Minimum entity health"),
+				(reader) -> {
+					final float minHealth = reader.getReader().readFloat();
+
+					if (minHealth > 0) {
+						reader.setPredicate((entity) -> entity instanceof LivingEntity livingEntity && livingEntity.getHealth() >= minHealth);
+					}
+				}
+		);
 	}
 
 	private int executeCommonCommand(CommandContext<ServerCommandSource> context) {
diff --git a/fabric-command-api-v2/src/testmod/java/net/fabricmc/fabric/test/command/EntitySelectorGameTest.java b/fabric-command-api-v2/src/testmod/java/net/fabricmc/fabric/test/command/EntitySelectorGameTest.java
new file mode 100644
index 000000000..a0bcbdf20
--- /dev/null
+++ b/fabric-command-api-v2/src/testmod/java/net/fabricmc/fabric/test/command/EntitySelectorGameTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.test.command;
+
+import java.util.Locale;
+
+import net.minecraft.entity.EntityType;
+import net.minecraft.entity.mob.MobEntity;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.test.GameTest;
+import net.minecraft.test.TestContext;
+import net.minecraft.util.math.BlockPos;
+
+import net.fabricmc.fabric.api.gametest.v1.FabricGameTest;
+
+public class EntitySelectorGameTest {
+	private void spawn(TestContext context, float health) {
+		MobEntity entity = context.spawnMob(EntityType.CREEPER, BlockPos.ORIGIN);
+		entity.setAiDisabled(true);
+		entity.setHealth(health);
+	}
+
+	@GameTest(templateName = FabricGameTest.EMPTY_STRUCTURE)
+	public void testEntitySelector(TestContext context) {
+		BlockPos absolute = context.getAbsolutePos(BlockPos.ORIGIN);
+
+		spawn(context, 1.0f);
+		spawn(context, 5.0f);
+		spawn(context, 10.0f);
+
+		String command = String.format(
+				Locale.ROOT,
+				"/kill @e[x=%d, y=%d, z=%d, distance=..2, %s=5.0]",
+				absolute.getX(),
+				absolute.getY(),
+				absolute.getZ(),
+				CommandTest.SELECTOR_ID.toUnderscoreSeparatedString()
+		);
+
+		context.expectEntitiesAround(EntityType.CREEPER, BlockPos.ORIGIN, 3, 2.0);
+		MinecraftServer server = context.getWorld().getServer();
+		int result = server.getCommandManager().executeWithPrefix(server.getCommandSource(), command);
+		context.assertTrue(result == 2, "Expected 2 entities killed, got " + result);
+		context.expectEntitiesAround(EntityType.CREEPER, BlockPos.ORIGIN, 1, 2.0);
+		context.complete();
+	}
+}
diff --git a/fabric-command-api-v2/src/testmod/resources/fabric.mod.json b/fabric-command-api-v2/src/testmod/resources/fabric.mod.json
index 1acfc5897..3c9bfda09 100644
--- a/fabric-command-api-v2/src/testmod/resources/fabric.mod.json
+++ b/fabric-command-api-v2/src/testmod/resources/fabric.mod.json
@@ -15,6 +15,9 @@
     ],
     "client": [
       "net.fabricmc.fabric.test.command.client.ClientCommandTest"
+    ],
+    "fabric-gametest": [
+      "net.fabricmc.fabric.test.command.EntitySelectorGameTest"
     ]
   }
 }