diff --git a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitCaptionKeys.java b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitCaptionKeys.java
index b64e7de5..7e512e27 100644
--- a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitCaptionKeys.java
+++ b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitCaptionKeys.java
@@ -92,6 +92,14 @@ public final class BukkitCaptionKeys {
*/
public static final Caption ARGUMENT_PARSE_FAILURE_NAMESPACED_KEY_NEED_NAMESPACE =
of("argument.parse.failure.namespacedkey.need_namespace");
+ /**
+ * Variables: {@code }
+ *
+ * @since 2.0.0
+ */
+ public static final Caption ARGUMENT_PARSE_FAILURE_ROTATION_INVALID_FORMAT = of(
+ "argument.parse.failure.rotation.invalid_format"
+ );
private BukkitCaptionKeys() {
}
diff --git a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitDefaultCaptionsProvider.java b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitDefaultCaptionsProvider.java
index 843a5cf4..12799079 100644
--- a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitDefaultCaptionsProvider.java
+++ b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitDefaultCaptionsProvider.java
@@ -84,6 +84,11 @@ public final class BukkitDefaultCaptionsProvider extends DelegatingCaptionPro
*/
public static final String ARGUMENT_PARSE_FAILURE_NAMESPACED_KEY_NEED_NAMESPACE =
"Invalid input '', requires an explicit namespace.";
+ /**
+ * Default caption for {@link BukkitCaptionKeys#ARGUMENT_PARSE_FAILURE_ROTATION_INVALID_FORMAT}
+ */
+ public static final String ARGUMENT_PARSE_FAILURE_ROTATION_INVALID_FORMAT =
+ "'' is not a valid rotation. Required format is ' '";
private static final CaptionProvider> PROVIDER = CaptionProvider.constantProvider()
.putCaption(
@@ -120,6 +125,10 @@ public final class BukkitDefaultCaptionsProvider extends DelegatingCaptionPro
BukkitCaptionKeys.ARGUMENT_PARSE_FAILURE_NAMESPACED_KEY_NEED_NAMESPACE,
ARGUMENT_PARSE_FAILURE_NAMESPACED_KEY_NEED_NAMESPACE
)
+ .putCaption(
+ BukkitCaptionKeys.ARGUMENT_PARSE_FAILURE_ROTATION_INVALID_FORMAT,
+ ARGUMENT_PARSE_FAILURE_ROTATION_INVALID_FORMAT
+ )
.build();
@SuppressWarnings("unchecked")
diff --git a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitParsers.java b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitParsers.java
index 7581b8c7..ebccce5e 100644
--- a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitParsers.java
+++ b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/BukkitParsers.java
@@ -45,6 +45,8 @@
import org.incendo.cloud.bukkit.parser.WorldParser;
import org.incendo.cloud.bukkit.parser.location.Location2DParser;
import org.incendo.cloud.bukkit.parser.location.LocationParser;
+import org.incendo.cloud.bukkit.parser.rotation.AngleParser;
+import org.incendo.cloud.bukkit.parser.rotation.RotationParser;
import org.incendo.cloud.bukkit.parser.selector.MultipleEntitySelectorParser;
import org.incendo.cloud.bukkit.parser.selector.MultiplePlayerSelectorParser;
import org.incendo.cloud.bukkit.parser.selector.SingleEntitySelectorParser;
@@ -74,7 +76,9 @@ public static void register(final CommandManager manager) {
.registerParser(Location2DParser.location2DParser())
.registerParser(ItemStackParser.itemStackParser())
.registerParser(SingleEntitySelectorParser.singleEntitySelectorParser())
- .registerParser(SinglePlayerSelectorParser.singlePlayerSelectorParser());
+ .registerParser(SinglePlayerSelectorParser.singlePlayerSelectorParser())
+ .registerParser(AngleParser.angleParser())
+ .registerParser(RotationParser.rotationParser());
/* Register Entity Selector Parsers */
manager.parserRegistry().registerAnnotationMapper(
diff --git a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/internal/BukkitBrigadierMapper.java b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/internal/BukkitBrigadierMapper.java
index 38b01eba..b4eb83e4 100644
--- a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/internal/BukkitBrigadierMapper.java
+++ b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/internal/BukkitBrigadierMapper.java
@@ -43,6 +43,8 @@
import org.incendo.cloud.bukkit.parser.NamespacedKeyParser;
import org.incendo.cloud.bukkit.parser.location.Location2DParser;
import org.incendo.cloud.bukkit.parser.location.LocationParser;
+import org.incendo.cloud.bukkit.parser.rotation.AngleParser;
+import org.incendo.cloud.bukkit.parser.rotation.RotationParser;
import org.incendo.cloud.bukkit.parser.selector.MultipleEntitySelectorParser;
import org.incendo.cloud.bukkit.parser.selector.MultiplePlayerSelectorParser;
import org.incendo.cloud.bukkit.parser.selector.SingleEntitySelectorParser;
@@ -97,6 +99,10 @@ public void registerBuiltInMappings() {
this.mapNMS(new TypeToken>() {}, "vec3", this::argumentVec3);
/* Map Vec2 */
this.mapNMS(new TypeToken>() {}, "vec2", this::argumentVec2);
+ /* Map Angle */
+ this.mapSimpleNMS(new TypeToken>() {}, "angle");
+ /* Map Rotation */
+ this.mapSimpleNMS(new TypeToken>() {}, "rotation");
}
@SuppressWarnings({"ConstantValue", "unused"})
diff --git a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/Angle.java b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/Angle.java
new file mode 100644
index 00000000..b4cee6c3
--- /dev/null
+++ b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/Angle.java
@@ -0,0 +1,112 @@
+//
+// MIT License
+//
+// Copyright (c) 2024 Incendo
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+package org.incendo.cloud.bukkit.parser.rotation;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+/**
+ * Represents an angle that can be applied to a reference angle.
+ *
+ * @since 2.0.0
+ */
+public final class Angle {
+
+ private final float angle;
+ private final boolean relative;
+
+ private Angle(
+ final float angle,
+ final boolean relative
+ ) {
+ this.angle = angle;
+ this.relative = relative;
+ }
+
+ /**
+ * Create a new angle object.
+ *
+ * @param angle angle
+ * @param relative whether the angle is relative
+ * @return Created angle instance.
+ */
+ public static @NonNull Angle of(
+ final float angle,
+ final boolean relative
+ ) {
+ return new Angle(angle, relative);
+ }
+
+ /**
+ * Returns the angle.
+ *
+ * @return angle
+ */
+ public float angle() {
+ return this.angle;
+ }
+
+ /**
+ * Returns if this angle is relative.
+ *
+ * @return whether the angle is relative
+ */
+ public boolean relative() {
+ return this.relative;
+ }
+
+ /**
+ * Applies this angle to a reference angle.
+ *
+ * @param angle the reference angle
+ * @return the modified angle
+ */
+ public float apply(final float angle) {
+ return this.relative ? this.angle + angle : this.angle;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Angle that = (Angle) o;
+ return Float.compare(this.angle, that.angle) == 0 && this.relative == that.relative;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Float.hashCode(this.angle);
+ result = 31 * result + Boolean.hashCode(this.relative);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Angle{angle=%s, relative=%s}", this.angle, this.relative);
+ }
+
+}
diff --git a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/AngleParser.java b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/AngleParser.java
new file mode 100644
index 00000000..03554d1d
--- /dev/null
+++ b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/AngleParser.java
@@ -0,0 +1,132 @@
+//
+// MIT License
+//
+// Copyright (c) 2024 Incendo
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+package org.incendo.cloud.bukkit.parser.rotation;
+
+import java.util.stream.Collectors;
+import org.apiguardian.api.API;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.incendo.cloud.component.CommandComponent;
+import org.incendo.cloud.context.CommandContext;
+import org.incendo.cloud.context.CommandInput;
+import org.incendo.cloud.parser.ArgumentParseResult;
+import org.incendo.cloud.parser.ArgumentParser;
+import org.incendo.cloud.parser.ParserDescriptor;
+import org.incendo.cloud.parser.standard.FloatParser;
+import org.incendo.cloud.parser.standard.IntegerParser;
+import org.incendo.cloud.suggestion.BlockingSuggestionProvider;
+import org.incendo.cloud.type.range.Range;
+
+
+/**
+ * Parser that parsers a {@link Angle} from two floats.
+ *
+ * @param Command sender type
+ * @since 2.0.0
+ */
+public final class AngleParser implements ArgumentParser, BlockingSuggestionProvider.Strings {
+
+ private static final Range SUGGESTION_RANGE = Range.intRange(Integer.MIN_VALUE, Integer.MAX_VALUE);
+
+ /**
+ * Creates a new angle parser.
+ *
+ * @param command sender type
+ * @return the created parser
+ * @since 2.0.0
+ */
+ @API(status = API.Status.STABLE, since = "2.0.0")
+ public static @NonNull ParserDescriptor angleParser() {
+ return ParserDescriptor.of(new AngleParser<>(), Angle.class);
+ }
+
+ /**
+ * Returns a {@link CommandComponent.Builder} using {@link #angleParser()} as the parser.
+ *
+ * @param the command sender type
+ * @return the component builder
+ * @since 2.0.0
+ */
+ @API(status = API.Status.STABLE, since = "2.0.0")
+ public static CommandComponent.@NonNull Builder angleComponent() {
+ return CommandComponent.builder().parser(angleParser());
+ }
+
+ @Override
+ public @NonNull ArgumentParseResult<@NonNull Angle> parse(
+ final @NonNull CommandContext<@NonNull C> commandContext,
+ final @NonNull CommandInput commandInput
+ ) {
+ final String input = commandInput.skipWhitespace().peekString();
+
+ final boolean relative;
+ if (commandInput.peek() == '~') {
+ relative = true;
+ commandInput.moveCursor(1);
+ } else {
+ relative = false;
+ }
+
+ final float angle;
+ try {
+ final boolean empty = commandInput.peekString().isEmpty() || commandInput.peek() == ' ';
+ angle = empty ? 0 : commandInput.readFloat();
+ } catch (final Exception e) {
+ return ArgumentParseResult.failure(new FloatParser.FloatParseException(
+ input,
+ new FloatParser<>(
+ FloatParser.DEFAULT_MINIMUM,
+ FloatParser.DEFAULT_MAXIMUM
+ ),
+ commandContext
+ ));
+ }
+
+ return ArgumentParseResult.success(
+ Angle.of(
+ angle,
+ relative
+ )
+ );
+ }
+
+ @Override
+ public @NonNull Iterable<@NonNull String> stringSuggestions(
+ final @NonNull CommandContext commandContext,
+ final @NonNull CommandInput input
+ ) {
+ String prefix;
+ if (input.hasRemainingInput() && input.peek() == '~') {
+ prefix = "~";
+ input.moveCursor(1);
+ } else {
+ prefix = "";
+ }
+
+ return IntegerParser.getSuggestions(
+ SUGGESTION_RANGE,
+ input
+ ).stream().map(string -> prefix + string).collect(Collectors.toList());
+ }
+
+}
diff --git a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/Rotation.java b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/Rotation.java
new file mode 100644
index 00000000..6839316e
--- /dev/null
+++ b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/Rotation.java
@@ -0,0 +1,116 @@
+//
+// MIT License
+//
+// Copyright (c) 2024 Incendo
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+package org.incendo.cloud.bukkit.parser.rotation;
+
+import java.util.Objects;
+import org.bukkit.Location;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+/**
+ * Represents a rotation that can be applied to a {@link Location}.
+ *
+ * @since 2.0.0
+ */
+public final class Rotation {
+
+ private final Angle yaw;
+ private final Angle pitch;
+
+ private Rotation(
+ final @NonNull Angle yaw,
+ final @NonNull Angle pitch
+ ) {
+ this.yaw = yaw;
+ this.pitch = pitch;
+ }
+
+ /**
+ * Create a new rotation object.
+ *
+ * @param yaw yaw
+ * @param pitch pitch
+ * @return Created rotation instance.
+ */
+ public static Rotation of(
+ final @NonNull Angle yaw,
+ final @NonNull Angle pitch
+ ) {
+ return new Rotation(yaw, pitch);
+ }
+
+ /**
+ * Returns the yaw of this rotation.
+ *
+ * @return yaw
+ */
+ public Angle yaw() {
+ return this.yaw;
+ }
+
+ /**
+ * Returns the pitch of this rotation
+ *
+ * @return pitch
+ */
+ public Angle pitch() {
+ return this.pitch;
+ }
+
+ /**
+ * Applies this rotation to a location.
+ *
+ * @param location the location to be modified
+ * @return the modified location
+ */
+ public @NonNull Location apply(
+ final @NonNull Location location
+ ) {
+ location.setYaw(this.yaw.apply(location.getYaw()));
+ location.setPitch(this.pitch.apply(location.getPitch()));
+ return location;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Rotation that = (Rotation) o;
+ return this.yaw.equals(that.yaw) && this.pitch.equals(that.pitch);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(this.yaw, this.pitch);
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Rotation{yaw=%s, pitch=%s}", this.yaw, this.pitch);
+ }
+
+}
diff --git a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/RotationParser.java b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/RotationParser.java
new file mode 100644
index 00000000..69023747
--- /dev/null
+++ b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/RotationParser.java
@@ -0,0 +1,179 @@
+//
+// MIT License
+//
+// Copyright (c) 2024 Incendo
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+package org.incendo.cloud.bukkit.parser.rotation;
+
+import java.util.stream.Collectors;
+import org.apiguardian.api.API;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.incendo.cloud.bukkit.BukkitCaptionKeys;
+import org.incendo.cloud.caption.CaptionVariable;
+import org.incendo.cloud.component.CommandComponent;
+import org.incendo.cloud.context.CommandContext;
+import org.incendo.cloud.context.CommandInput;
+import org.incendo.cloud.exception.parsing.ParserException;
+import org.incendo.cloud.parser.ArgumentParseResult;
+import org.incendo.cloud.parser.ArgumentParser;
+import org.incendo.cloud.parser.ParserDescriptor;
+import org.incendo.cloud.parser.standard.IntegerParser;
+import org.incendo.cloud.suggestion.BlockingSuggestionProvider;
+import org.incendo.cloud.type.range.Range;
+
+/**
+ * Parser that parses a {@link Rotation} from two floats.
+ *
+ * @param Command sender type
+ * @since 2.0.0
+ */
+public final class RotationParser implements ArgumentParser, BlockingSuggestionProvider.Strings {
+
+ private static final Range SUGGESTION_RANGE = Range.intRange(Integer.MIN_VALUE, Integer.MAX_VALUE);
+
+ private final AngleParser angleParser = new AngleParser<>();
+
+ /**
+ * Creates a new rotation parser.
+ *
+ * @param command sender type
+ * @return the created parser
+ * @since 2.0.0
+ */
+ @API(status = API.Status.STABLE, since = "2.0.0")
+ public static @NonNull ParserDescriptor rotationParser() {
+ return ParserDescriptor.of(new RotationParser<>(), Rotation.class);
+ }
+
+ /**
+ * Returns a {@link CommandComponent.Builder} using {@link #rotationParser()} as the parser.
+ *
+ * @param the command sender type
+ * @return the component builder
+ * @since 2.0.0
+ */
+ @API(status = API.Status.STABLE, since = "2.0.0")
+ public static CommandComponent.@NonNull Builder rotationComponent() {
+ return CommandComponent.builder().parser(rotationParser());
+ }
+
+ @Override
+ public @NonNull ArgumentParseResult<@NonNull Rotation> parse(
+ final @NonNull CommandContext commandContext,
+ final @NonNull CommandInput commandInput
+ ) {
+ if (commandInput.remainingTokens() < 2) {
+ return ArgumentParseResult.failure(
+ new RotationParseException(
+ commandContext,
+ commandInput.remainingInput()
+ )
+ );
+ }
+
+ Angle[] angles = new Angle[2];
+ for (int i = 0; i < 2; i++) {
+ if (commandInput.peekString().isEmpty()) {
+ return ArgumentParseResult.failure(
+ new RotationParseException(
+ commandContext,
+ commandInput.remainingInput()
+ )
+ );
+ }
+
+ final ArgumentParseResult angle = this.angleParser.parse(
+ commandContext,
+ commandInput
+ );
+ if (angle.failure().isPresent()) {
+ return ArgumentParseResult.failure(
+ angle.failure().get()
+ );
+ }
+
+ angles[i] = angle.parsedValue().orElseThrow(NullPointerException::new);
+ }
+
+ return ArgumentParseResult.success(
+ Rotation.of(
+ angles[0],
+ angles[1]
+ )
+ );
+ }
+
+ @Override
+ public @NonNull Iterable<@NonNull String> stringSuggestions(
+ final @NonNull CommandContext commandContext,
+ final @NonNull CommandInput input
+ ) {
+ final CommandInput inputCopy = input.copy();
+
+ int idx = input.cursor();
+ for (int i = 0; i < 2; i++) {
+ input.skipWhitespace();
+ idx = input.cursor();
+
+ if (!input.hasRemainingInput()) {
+ break;
+ }
+
+ final ArgumentParseResult angle = this.angleParser.parse(
+ commandContext,
+ input
+ );
+
+ if (angle.failure().isPresent() || !input.hasRemainingInput()) {
+ break;
+ }
+ }
+ input.cursor(idx);
+
+ if (input.hasRemainingInput() && input.peek() == '~') {
+ input.moveCursor(1);
+ }
+
+ final String prefix = inputCopy.difference(input, true);
+
+ return IntegerParser.getSuggestions(
+ SUGGESTION_RANGE,
+ input
+ ).stream().map(string -> prefix + string).collect(Collectors.toList());
+ }
+
+ static class RotationParseException extends ParserException {
+
+ protected RotationParseException(
+ final @NonNull CommandContext> context,
+ final @NonNull String input
+ ) {
+ super(
+ RotationParser.class,
+ context,
+ BukkitCaptionKeys.ARGUMENT_PARSE_FAILURE_ROTATION_INVALID_FORMAT,
+ CaptionVariable.of("input", input)
+ );
+ }
+
+ }
+
+}
diff --git a/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/package-info.java b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/package-info.java
new file mode 100644
index 00000000..8e90b056
--- /dev/null
+++ b/cloud-bukkit/src/main/java/org/incendo/cloud/bukkit/parser/rotation/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Vanilla-like rotation arguments
+ */
+package org.incendo.cloud.bukkit.parser.rotation;
diff --git a/cloud-bukkit/src/test/java/org/incendo/cloud/bukkit/parser/AngleArgumentTest.java b/cloud-bukkit/src/test/java/org/incendo/cloud/bukkit/parser/AngleArgumentTest.java
new file mode 100644
index 00000000..6593f9b5
--- /dev/null
+++ b/cloud-bukkit/src/test/java/org/incendo/cloud/bukkit/parser/AngleArgumentTest.java
@@ -0,0 +1,97 @@
+//
+// MIT License
+//
+// Copyright (c) 2024 Incendo
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+package org.incendo.cloud.bukkit.parser;
+
+import java.util.stream.Stream;
+import org.bukkit.command.CommandSender;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.incendo.cloud.bukkit.parser.rotation.Angle;
+import org.incendo.cloud.bukkit.parser.rotation.AngleParser;
+import org.incendo.cloud.bukkit.util.ServerTest;
+import org.incendo.cloud.context.CommandInput;
+import org.incendo.cloud.parser.ArgumentParseResult;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+public class AngleArgumentTest extends ServerTest {
+
+ @ParameterizedTest
+ @MethodSource("Parse_HappyFlow_Success_Source")
+ void Parse_HappyFlow_Success(final @NonNull String input, final float expectedAngle) {
+ // Arrange
+ final AngleParser parser = new AngleParser<>();
+ final CommandInput commandInput = CommandInput.of(input);
+
+ // Act
+ final ArgumentParseResult result = parser.parse(
+ this.commandContext(),
+ commandInput
+ );
+
+ // Assert
+ assertThat(result.failure()).isEmpty();
+ assertThat(result.parsedValue().isPresent()).isTrue();
+ assertThat(result.parsedValue().get().apply(0)).isEqualTo(expectedAngle);
+ assertThat(commandInput.remainingInput()).isEmpty();
+ }
+
+ static @NonNull Stream<@NonNull Arguments> Parse_HappyFlow_Success_Source() {
+ return Stream.of(
+ arguments("~", 0f),
+ arguments("~10", 10f),
+ arguments("~-10", -10f),
+ arguments("~0.5", 0.5f),
+ arguments("~-0.5", -0.5f),
+ arguments("0", 0f),
+ arguments("10", 10f),
+ arguments("-10", -10f),
+ arguments("0.5", 0.5f),
+ arguments("-0.5", -0.5f)
+ );
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"^", "^0", "not an angle"})
+ void Parse_InvalidAngle_Failure(final @NonNull String input) {
+ // Arrange
+ final AngleParser parser = new AngleParser<>();
+ final CommandInput commandInput = CommandInput.of(input);
+
+ // Act
+ final ArgumentParseResult result = parser.parse(
+ this.commandContext(),
+ commandInput
+ );
+
+ // Assert
+ assertThat(result.failure()).isPresent();
+ assertThat(result.parsedValue()).isEmpty();
+ }
+
+}
diff --git a/cloud-bukkit/src/test/java/org/incendo/cloud/bukkit/parser/RotationArgumentTest.java b/cloud-bukkit/src/test/java/org/incendo/cloud/bukkit/parser/RotationArgumentTest.java
new file mode 100644
index 00000000..ba3b6a93
--- /dev/null
+++ b/cloud-bukkit/src/test/java/org/incendo/cloud/bukkit/parser/RotationArgumentTest.java
@@ -0,0 +1,180 @@
+//
+// MIT License
+//
+// Copyright (c) 2024 Incendo
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+package org.incendo.cloud.bukkit.parser;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.bukkit.Location;
+import org.bukkit.command.CommandSender;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.incendo.cloud.CommandManager;
+import org.incendo.cloud.bukkit.parser.rotation.Rotation;
+import org.incendo.cloud.bukkit.parser.rotation.RotationParser;
+import org.incendo.cloud.bukkit.util.ServerTest;
+import org.incendo.cloud.context.CommandInput;
+import org.incendo.cloud.execution.ExecutionCoordinator;
+import org.incendo.cloud.internal.CommandRegistrationHandler;
+import org.incendo.cloud.parser.ArgumentParseResult;
+import org.incendo.cloud.parser.flag.CommandFlag;
+import org.incendo.cloud.suggestion.Suggestion;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+public class RotationArgumentTest extends ServerTest {
+
+ @ParameterizedTest
+ @MethodSource("Parse_HappyFlow_Success_Source")
+ void Parse_HappyFlow_Success(final @NonNull String input, final @NonNull Location expectedLocation) {
+ // Arrange
+ final RotationParser parser = new RotationParser<>();
+ final CommandInput commandInput = CommandInput.of(input);
+
+ // Act
+ final ArgumentParseResult result = parser.parse(
+ this.commandContext(),
+ commandInput
+ );
+
+ // Assert
+ assertThat(result.failure()).isEmpty();
+ assertThat(result.parsedValue().isPresent()).isTrue();
+ assertThat(result.parsedValue().get().apply(new Location(null, 0, 0, 0, 10, 20))).isEqualTo(expectedLocation);
+ assertThat(commandInput.remainingInput()).isEmpty();
+ }
+
+ static @NonNull Stream<@NonNull Arguments> Parse_HappyFlow_Success_Source() {
+ return Stream.of(
+ arguments("~ ~", new Location(null, 0, 0, 0, 10, 20)),
+ arguments("~10 ~10", new Location(null, 0, 0, 0, 20, 30)),
+ arguments("~-10 ~-10", new Location(null, 0, 0, 0, 0, 10)),
+ arguments("~0.5 ~-0.5", new Location(null, 0, 0, 0, 10.5f, 19.5f)),
+ arguments("~-0.5 ~0.5", new Location(null, 0, 0, 0, 9.5f, 20.5f)),
+ arguments("0 0", new Location(null, 0, 0, 0, 0, 0)),
+ arguments("10 10", new Location(null, 0, 0, 0, 10, 10)),
+ arguments("-10 -10", new Location(null, 0, 0, 0, -10, -10)),
+ arguments("0.5 -0.5", new Location(null, 0, 0, 0, 0.5f, -0.5f)),
+ arguments("-0.5 0.5", new Location(null, 0, 0, 0, -0.5f, 0.5f))
+ );
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"^ ^", "0 ^", "0", "0~", "0 not an angle", "not an angle"})
+ void Parse_InvalidAngle_Failure(final @NonNull String input) {
+ // Arrange
+ final RotationParser parser = new RotationParser<>();
+ final CommandInput commandInput = CommandInput.of(input);
+
+ // Act
+ final ArgumentParseResult result = parser.parse(
+ this.commandContext(),
+ commandInput
+ );
+
+ // Assert
+ assertThat(result.failure()).isPresent();
+ assertThat(result.parsedValue()).isEmpty();
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void suggestions(final String input, final List expectedSuggestions) {
+ final CommandManager manager = new CommandManager(
+ ExecutionCoordinator.simpleCoordinator(), CommandRegistrationHandler.nullCommandRegistrationHandler()
+ ) {
+ @Override
+ public boolean hasPermission(@NonNull CommandSender sender, @NonNull String permission) {
+ return true;
+ }
+ };
+
+ manager.command(
+ manager.commandBuilder("rotation")
+ .required("rotation", RotationParser.rotationParser())
+ );
+
+ final Function suggestion = s -> "rotation " + s;
+ assertEquals(
+ expectedSuggestions,
+ manager.suggestionFactory().suggestImmediately(this.commandContext().sender(), suggestion.apply(input))
+ .list().stream().map(Suggestion::suggestion).collect(Collectors.toList())
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("suggestions")
+ void suggestionsFlag(final String input, final List expectedSuggestions) {
+ final CommandManager manager = new CommandManager(
+ ExecutionCoordinator.simpleCoordinator(), CommandRegistrationHandler.nullCommandRegistrationHandler()
+ ) {
+ @Override
+ public boolean hasPermission(@NonNull CommandSender sender, @NonNull String permission) {
+ return true;
+ }
+ };
+
+ manager.command(
+ manager.commandBuilder("flag")
+ .flag(CommandFlag.builder("rot").withComponent(RotationParser.rotationParser()).build())
+ );
+
+ final Function flagSuggestion = s -> "flag --rot " + s;
+ assertEquals(
+ expectedSuggestions,
+ manager.suggestionFactory().suggestImmediately(this.commandContext().sender(), flagSuggestion.apply(input))
+ .list().stream().map(Suggestion::suggestion).collect(Collectors.toList())
+ );
+ }
+
+ static Stream suggestions() {
+ return Stream.of(
+ arguments("", Arrays.asList("0", "1", "2", "3", "4", "5", "6", "7", "8", "9")),
+ arguments("1", Arrays.asList("1", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19")),
+ arguments("1 ", Arrays.asList("1 0", "1 1", "1 2", "1 3", "1 4", "1 5", "1 6", "1 7", "1 8", "1 9")),
+ arguments("1 1", Arrays.asList("1 1", "1 10", "1 11", "1 12", "1 13", "1 14", "1 15", "1 16", "1 17", "1 18", "1 19")),
+ arguments("1 0", Collections.singletonList("1 0")),
+ arguments("1 1 ", Collections.emptyList()),
+ arguments("0", Collections.singletonList("0")),
+ arguments("0 ", Arrays.asList("0 0", "0 1", "0 2", "0 3", "0 4", "0 5", "0 6", "0 7", "0 8", "0 9")),
+ arguments("0 1", Arrays.asList("0 1", "0 10", "0 11", "0 12", "0 13", "0 14", "0 15", "0 16", "0 17", "0 18", "0 19")),
+ arguments("0 0", Collections.singletonList("0 0")),
+ arguments("0 0 ", Collections.emptyList()),
+ arguments("~", Arrays.asList("~0", "~1", "~2", "~3", "~4", "~5", "~6", "~7", "~8", "~9")),
+ arguments("~ ~", Arrays.asList("~ ~0", "~ ~1", "~ ~2", "~ ~3", "~ ~4", "~ ~5", "~ ~6", "~ ~7", "~ ~8", "~ ~9")),
+ arguments("~0", Collections.singletonList("~0")),
+ arguments("~1", Arrays.asList("~1", "~10", "~11", "~12", "~13", "~14", "~15", "~16", "~17", "~18", "~19")),
+ arguments("~1 ", Arrays.asList("~1 0", "~1 1", "~1 2", "~1 3", "~1 4", "~1 5", "~1 6", "~1 7", "~1 8", "~1 9"))
+ );
+ }
+
+}