1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2025-01-23 15:36:54 +00:00

Rewrite computer selectors

This adds support for computer selectors, in the style of entity
selectors. The long-term goal here is to replace our existing ad-hoc
selectors. However, to aid migration, we currently support both - the
previous one will most likely be removed in MC 1.21.

Computer selectors take the form @c[<key>=<value>,...]. Currently we
support filtering by id, instance id, label, family (as before) and
distance from the player (new!). The code also supports computers within
a bounding box, but there's no parsing support for that yet.

This commit also (finally) documents the /computercraft command. Well,
sort of - it's definitely not my best word, but I couldn't find better
words.
This commit is contained in:
Jonathan Coates 2024-03-12 20:12:13 +00:00
parent cb8e06af2a
commit b7df91349a
No known key found for this signature in database
GPG Key ID: B9E431FF07C98D06
16 changed files with 737 additions and 264 deletions

View File

@ -21,5 +21,5 @@ SPDX-License-Identifier: MPL-2.0
<suppress checks="PackageName" files=".*[\\/]T[A-Za-z]+.java" />
<!-- Allow underscores in our test classes. -->
<suppress checks="MethodName" files=".*Contract.java" />
<suppress checks="MethodName" files=".*(Contract|Test).java" />
</suppressions>

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

140
doc/reference/command.md Normal file
View File

@ -0,0 +1,140 @@
---
module: [kind=reference] computercraft_command
---
<!--
SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
SPDX-License-Identifier: MPL-2.0
-->
# The `/computercraft` command
CC: Tweaked provides a `/computercraft` command for server owners to manage running computers on a server.
## Permissions {#permissions}
As the `/computercraft` command is mostly intended for debugging and administrative purposes, its sub-commands typically
require you to have op (or similar).
- All players have access to the [`queue`] sub-command.
- On a multi-player server, all other commands require op.
- On a single-player world, the player can run the [`dump`], [`turn-on`]/[`shutdown`], and [`track`] sub-commands, even
when cheats are not enabled. The [`tp`] and [`view`] commands require cheats.
If a permission mod such as [LuckPerms] is installed[^permission], you can configure access to the individual
sub-commands. Each sub-command creates a `computercraft.command.NAME` permission node to control which players can
execute it.
[LuckPerms]: https://github.com/LuckPerms/LuckPerms/ "A permissions plugin for Minecraft servers."
[fabric-permission-api]: https://github.com/lucko/fabric-permissions-api "A simple permissions API for Fabric"
[^permission]: This supports any mod which uses Forge's permission API or [fabric-permission-api].
## Computer selectors {#computer-selectors}
Some commands (such as [`tp`] or [`turn-on`]) target a specific computer, or a list of computers. To specify which
computers to operate on, you must use "computer selectors".
Computer selectors are similar to Minecraft's [entity target selectors], but targeting computers instead. They allow
you to select one or more computers, based on a set of predicates.
The following predicates are supported:
- `id=<id>`: Select computer(s) with a specific id.
- `instance=<id>`: Select the computer with the given instance id.
- `family=<normal|advanced|command>`: Select computers based on their type.
- `label=<label>`: Select computers with the given label.
- `distance=<distance>`: Select computers within a specific distance of the player executing the command. This uses
Minecraft's [float range] syntax.
`#<id>` may also be used as a shorthand for `@c[id=<id>]`, to select computer(s) with a specific id.
### Examples:
- `/computercraft turn-on #12`: Turn on the computer(s) with an id of 12.
- `/computercraft shutdown @c[distance=..100]`: Shut down all computers with 100 blocks of the player.
[entity target selectors]: https://minecraft.wiki/w/Target_selectors "Target Selectors on the Minecraft wiki"
[Float range]: https://minecraft.wiki/w/Argument_types#minecraft:float_range
## Commands {#commands}
### `/computercraft dump` {#dump}
`/computercraft dump` prints a table of currently loaded computers, including their id, position, and whether they're
running. It can also be run with a single computer argument to dump more detailed information about a computer.
![A screenshot of a Minecraft world. In the chat box, there is a table listing 5 computers, with columns labelled
"Computer", "On" and "Position". Below that, is a more detailed list of information about Computer 0, including its
label ("My computer") and that it has a monitor on the right hand side](../images/computercraft-dump.png "An example of
running '/computercraft dump'")
Next to the computer id, there are several buttons to either [teleport][`tp`] to the computer, or [open its terminal
][`view`].
Computers are sorted by distance to the player, so nearby computers will appear earlier.
### `/computercraft turn-on [computers...]` {#turn-on}
Turn on one or more computers or, if no run with no arguments, all loaded computers.
#### Examples
- `/computercraft turn-on #0 #2`: Turn on computers with id 0 and 2.
- `/computercraft turn-on @c[family=command]`: Turn on all command computers.
### `/computercraft shutdown [computers...]` {#shutdown}
Shutdown one or more computers or, if no run with no arguments, all loaded computers.
This is sometimes useful when dealing with lag, as a way to ensure that ComputerCraft is not causing problems.
#### Examples
- `/computercraft shutdown`: Shut down all loaded computers.
- `/computercraft shutdown @c[distance=..10]`: Shut down all computers in a block radius.
### `/computercraft tp [computer]` {#tp}
Teleport to the given computer.
This is normally used from via the [`dump`] command interface rather than being invoked directly.
### `/computercraft view [computer]` {#view}
Open a terminal for the specified computer. This allows remotely viewing computers without having to interact with the
block.
This is normally used from via the [`dump`] command interface rather than being invoked directly.
### `/computercraft track` {#track}
The `/computercraft track` command allows you to enable profiling of computers. When a computer runs code, or interacts
with the Minecraft world, we time how long that takes. This timing information may then be queried, and used to find
computers which may be causing lag.
To enable the profiler, run `/computercraft track start`. Computers will then start recording metrics. Once enough data
has been gathered, run `/computercraft track stop` to stop profiling and display the recorded data.
![](../images/computercraft-track.png)
The table by default shows the number of times each computer has run, and how long it ran for (in total, and on
average). In the above screenshot, we can see one computer was particularly badly behaved, and ran for 7 seconds. The
buttons may be used to [teleport][`tp`] to the computer, or [open its terminal ][`view`], and inspect it further.
`/computercraft track dump` can be used to display this table at any point (including while profiling is still running).
Computers also record other information, such as how much server-thread time they consume, or their HTTP bandwidth
usage. The `dump` subcommand accepts a list of other fields to display, instead of the default timings.
#### Examples
- `/computercraft track dump server_tasks_count server_tasks`: Print the number of server-thread tasks each computer
executed, and how long they took in total.
- `/computercraft track dump http_upload http_download`: Print the number of bytes uploaded and downloaded by each
computer.
### `/computercraft queue` {#queue}
The queue subcommand allows non-operator players to queue a `computer_command` event on *command* computers.
This has a similar purpose to vanilla's [`/trigger`] command. Command computers may choose to listen to this event, and
then perform some action.
[`/trigger`]: https://minecraft.wiki/w/Commands/trigger "/trigger on the Minecraft wiki"
[`dump`]: #dump "/computercraft dump"
[`queue`]: #queue "/computercraft queue"
[`shutdown`]: #shutdown "/computercraft shutdown"
[`tp`]: #tp "/computercraft tp"
[`track`]: #track "/computercraft track"
[`turn-on`]: #turn-on "/computercraft turn-on"
[`view`]: #view "/computercraft view"
[computer selectors]: #computer-selectors "Computer selectors"

View File

@ -13,6 +13,7 @@ import dan200.computercraft.api.upgrades.UpgradeBase;
import dan200.computercraft.core.metrics.Metric;
import dan200.computercraft.core.metrics.Metrics;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.command.arguments.ComputerSelector;
import dan200.computercraft.shared.computer.metrics.basic.Aggregate;
import dan200.computercraft.shared.computer.metrics.basic.AggregatedMetric;
import dan200.computercraft.shared.config.ConfigFile;
@ -166,10 +167,19 @@ public final class LanguageProvider implements DataProvider {
add("commands.computercraft.generic.exception", "Unhandled exception (%s)");
add("commands.computercraft.generic.additional_rows", "%d additional rows…");
// Argument types
add("argument.computercraft.computer.instance", "Unique instance ID");
add("argument.computercraft.computer.id", "Computer ID");
add("argument.computercraft.computer.label", "Computer label");
add("argument.computercraft.computer.distance", "Distance to entity");
add("argument.computercraft.computer.family", "Computer family");
// Exceptions
add("argument.computercraft.computer.no_matching", "No computers matching '%s'");
add("argument.computercraft.computer.many_matching", "Multiple computers matching '%s' (instances %s)");
add("argument.computercraft.tracking_field.no_field", "Unknown field '%s'");
add("argument.computercraft.argument_expected", "Argument expected");
add("argument.computercraft.unknown_computer_family", "Unknown computer family '%s'");
// Metrics
add(Metrics.COMPUTER_TASKS, "Tasks");
@ -282,7 +292,8 @@ public final class LanguageProvider implements DataProvider {
pocketUpgrades.getGeneratedUpgrades().stream().map(UpgradeBase::getUnlocalisedAdjective),
Metric.metrics().values().stream().map(x -> AggregatedMetric.TRANSLATION_PREFIX + x.name() + ".name"),
ConfigSpec.serverSpec.entries().map(ConfigFile.Entry::translationKey),
ConfigSpec.clientSpec.entries().map(ConfigFile.Entry::translationKey)
ConfigSpec.clientSpec.entries().map(ConfigFile.Entry::translationKey),
ComputerSelector.options().values().stream().map(ComputerSelector.Option::translationKey)
).flatMap(x -> x);
}

View File

@ -17,7 +17,6 @@ import dan200.computercraft.impl.PocketUpgrades;
import dan200.computercraft.impl.TurtleUpgrades;
import dan200.computercraft.shared.command.UserLevel;
import dan200.computercraft.shared.command.arguments.ComputerArgumentType;
import dan200.computercraft.shared.command.arguments.ComputersArgumentType;
import dan200.computercraft.shared.command.arguments.RepeatArgumentType;
import dan200.computercraft.shared.command.arguments.TrackingFieldArgumentType;
import dan200.computercraft.shared.common.ClearColourRecipe;
@ -337,8 +336,7 @@ public final class ModRegistry {
static {
register("tracking_field", TrackingFieldArgumentType.class, TrackingFieldArgumentType.metric());
register("computer", ComputerArgumentType.class, ComputerArgumentType.oneComputer());
register("computers", ComputersArgumentType.class, new ComputersArgumentType.Info());
register("computer", ComputerArgumentType.class, ComputerArgumentType.get());
registerUnsafe("repeat", RepeatArgumentType.class, new RepeatArgumentType.Info());
}
}

View File

@ -12,7 +12,8 @@ import com.mojang.brigadier.suggestion.Suggestions;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.core.metrics.Metrics;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.command.arguments.ComputersArgumentType;
import dan200.computercraft.shared.command.arguments.ComputerArgumentType;
import dan200.computercraft.shared.command.arguments.ComputerSelector;
import dan200.computercraft.shared.command.text.TableBuilder;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.core.ServerComputer;
@ -23,6 +24,7 @@ import dan200.computercraft.shared.computer.metrics.basic.AggregatedMetric;
import dan200.computercraft.shared.computer.metrics.basic.BasicComputerMetricsObserver;
import dan200.computercraft.shared.computer.metrics.basic.ComputerMetrics;
import dan200.computercraft.shared.network.container.ComputerContainerData;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
@ -42,9 +44,6 @@ import java.util.*;
import static dan200.computercraft.shared.command.CommandUtils.isPlayer;
import static dan200.computercraft.shared.command.Exceptions.NOT_TRACKING_EXCEPTION;
import static dan200.computercraft.shared.command.Exceptions.NO_TIMINGS_EXCEPTION;
import static dan200.computercraft.shared.command.arguments.ComputerArgumentType.getComputerArgument;
import static dan200.computercraft.shared.command.arguments.ComputerArgumentType.oneComputer;
import static dan200.computercraft.shared.command.arguments.ComputersArgumentType.*;
import static dan200.computercraft.shared.command.arguments.TrackingFieldArgumentType.metric;
import static dan200.computercraft.shared.command.builder.CommandBuilder.args;
import static dan200.computercraft.shared.command.builder.CommandBuilder.command;
@ -70,37 +69,37 @@ public final class CommandComputerCraft {
.requires(ModRegistry.Permissions.PERMISSION_DUMP)
.executes(c -> dump(c.getSource()))
.then(args()
.arg("computer", oneComputer())
.executes(c -> dumpComputer(c.getSource(), getComputerArgument(c, "computer")))))
.arg("computer", ComputerArgumentType.get())
.executes(c -> dumpComputer(c.getSource(), ComputerArgumentType.getOne(c, "computer")))))
.then(command("shutdown")
.requires(ModRegistry.Permissions.PERMISSION_SHUTDOWN)
.argManyValue("computers", manyComputers(), s -> ServerContext.get(s.getServer()).registry().getComputers())
.argManyValue("computers", ComputerArgumentType.get(), ComputerSelector.all())
.executes((c, a) -> shutdown(c.getSource(), unwrap(c.getSource(), a))))
.then(command("turn-on")
.requires(ModRegistry.Permissions.PERMISSION_TURN_ON)
.argManyValue("computers", manyComputers(), s -> ServerContext.get(s.getServer()).registry().getComputers())
.argManyValue("computers", ComputerArgumentType.get(), ComputerSelector.all())
.executes((c, a) -> turnOn(c.getSource(), unwrap(c.getSource(), a))))
.then(command("tp")
.requires(ModRegistry.Permissions.PERMISSION_TP)
.arg("computer", oneComputer())
.executes(c -> teleport(c.getSource(), getComputerArgument(c, "computer"))))
.arg("computer", ComputerArgumentType.get())
.executes(c -> teleport(c.getSource(), ComputerArgumentType.getOne(c, "computer"))))
.then(command("queue")
.requires(ModRegistry.Permissions.PERMISSION_QUEUE)
.arg(
RequiredArgumentBuilder.<CommandSourceStack, ComputersArgumentType.ComputersSupplier>argument("computer", manyComputers())
RequiredArgumentBuilder.<CommandSourceStack, ComputerSelector>argument("computer", ComputerArgumentType.get())
.suggests((context, builder) -> Suggestions.empty())
)
.argManyValue("args", StringArgumentType.string(), List.of())
.executes((c, a) -> queue(getComputersArgument(c, "computer"), a)))
.executes((c, a) -> queue(ComputerArgumentType.getMany(c, "computer"), a)))
.then(command("view")
.requires(ModRegistry.Permissions.PERMISSION_VIEW)
.arg("computer", oneComputer())
.executes(c -> view(c.getSource(), getComputerArgument(c, "computer"))))
.arg("computer", ComputerArgumentType.get())
.executes(c -> view(c.getSource(), ComputerArgumentType.getOne(c, "computer"))))
.then(choice("track")
.requires(ModRegistry.Permissions.PERMISSION_TRACK)
@ -332,29 +331,26 @@ public final class CommandComputerCraft {
// Additional helper functions.
private static Component linkComputer(CommandSourceStack source, @Nullable ServerComputer serverComputer, int computerId) {
private static Component linkComputer(CommandSourceStack source, @Nullable ServerComputer computer, int computerId) {
var out = Component.literal("");
// Append the computer instance
if (serverComputer == null) {
out.append(text("?"));
// And instance
if (computer == null) {
out.append("#" + computerId + " ").append(coloured("(unloaded)", ChatFormatting.GRAY));
} else {
out.append(link(
text(Integer.toString(serverComputer.getInstanceID())),
"/computercraft dump " + serverComputer.getInstanceID(),
text("#" + computerId + " ").append(coloured("(instance " + computer.getInstanceID() + ")", ChatFormatting.GRAY)),
makeComputerCommand("dump", computer),
Component.translatable("commands.computercraft.dump.action")
));
}
// And ID
out.append(" (id " + computerId + ")");
// And, if we're a player, some useful links
if (serverComputer != null && isPlayer(source)) {
if (computer != null && isPlayer(source)) {
if (ModRegistry.Permissions.PERMISSION_TP.test(source)) {
out.append(" ").append(link(
text("\u261b"),
"/computercraft tp " + serverComputer.getInstanceID(),
makeComputerCommand("tp", computer),
Component.translatable("commands.computercraft.tp.action")
));
}
@ -362,7 +358,7 @@ public final class CommandComputerCraft {
if (ModRegistry.Permissions.PERMISSION_VIEW.test(source)) {
out.append(" ").append(link(
text("\u20e2"),
"/computercraft view " + serverComputer.getInstanceID(),
makeComputerCommand("view", computer),
Component.translatable("commands.computercraft.view.action")
));
}
@ -376,11 +372,15 @@ public final class CommandComputerCraft {
return out;
}
private static String makeComputerCommand(String command, ServerComputer computer) {
return String.format("/computercraft %s @c[instance=%d]", command, computer.getInstanceID());
}
private static Component linkPosition(CommandSourceStack context, ServerComputer computer) {
if (ModRegistry.Permissions.PERMISSION_TP.test(context)) {
return link(
position(computer.getPosition()),
"/computercraft tp " + computer.getInstanceID(),
makeComputerCommand("tp", computer),
Component.translatable("commands.computercraft.tp.action")
);
} else {
@ -431,4 +431,10 @@ public final class CommandComputerCraft {
table.display(source);
return timings.size();
}
public static Set<ServerComputer> unwrap(CommandSourceStack source, Collection<ComputerSelector> suppliers) {
Set<ServerComputer> computers = new HashSet<>();
for (var supplier : suppliers) supplier.find(source).forEach(computers::add);
return computers;
}
}

View File

@ -28,12 +28,12 @@ public final class CommandUtils {
@SuppressWarnings("unchecked")
public static CompletableFuture<Suggestions> suggestOnServer(CommandContext<?> context, Function<CommandContext<CommandSourceStack>, CompletableFuture<Suggestions>> supplier) {
var source = context.getSource();
if (!(source instanceof SharedSuggestionProvider)) {
if (!(source instanceof SharedSuggestionProvider shared)) {
return Suggestions.empty();
} else if (source instanceof CommandSourceStack) {
return supplier.apply((CommandContext<CommandSourceStack>) context);
} else {
return ((SharedSuggestionProvider) source).customSuggestion(context);
return shared.customSuggestion(context);
}
}

View File

@ -7,6 +7,8 @@ package dan200.computercraft.shared.command;
import com.mojang.brigadier.exceptions.Dynamic2CommandExceptionType;
import com.mojang.brigadier.exceptions.DynamicCommandExceptionType;
import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
import net.minecraft.commands.arguments.selector.EntitySelectorParser;
import net.minecraft.commands.arguments.selector.options.EntitySelectorOptions;
import net.minecraft.network.chat.Component;
public final class Exceptions {
@ -20,6 +22,13 @@ public final class Exceptions {
public static final SimpleCommandExceptionType ARGUMENT_EXPECTED = translated("argument.computercraft.argument_expected");
public static final DynamicCommandExceptionType UNKNOWN_FAMILY = translated1("argument.computercraft.unknown_computer_family");
public static final DynamicCommandExceptionType ERROR_EXPECTED_OPTION_VALUE = EntitySelectorParser.ERROR_EXPECTED_OPTION_VALUE;
public static final SimpleCommandExceptionType ERROR_EXPECTED_END_OF_OPTIONS = EntitySelectorParser.ERROR_EXPECTED_END_OF_OPTIONS;
public static final DynamicCommandExceptionType ERROR_UNKNOWN_OPTION = EntitySelectorOptions.ERROR_UNKNOWN_OPTION;
public static final DynamicCommandExceptionType ERROR_INAPPLICABLE_OPTION = EntitySelectorOptions.ERROR_INAPPLICABLE_OPTION;
private static SimpleCommandExceptionType translated(String key) {
return new SimpleCommandExceptionType(Component.translatable(key));
}

View File

@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.command.arguments;
import com.mojang.brigadier.StringReader;
final class ArgumentParserUtils {
private ArgumentParserUtils() {
}
public static boolean consume(StringReader reader, char lookahead) {
if (!reader.canRead() || reader.peek() != lookahead) return false;
reader.skip();
return true;
}
public static boolean consume(StringReader reader, String lookahead) {
if (!reader.canRead(lookahead.length())) return false;
for (var i = 0; i < lookahead.length(); i++) {
if (reader.peek(i) != lookahead.charAt(i)) return false;
}
reader.setCursor(reader.getCursor() + lookahead.length());
return true;
}
}

View File

@ -14,66 +14,58 @@ import dan200.computercraft.shared.computer.core.ServerComputer;
import net.minecraft.commands.CommandSourceStack;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import static dan200.computercraft.shared.command.Exceptions.COMPUTER_ARG_MANY;
public final class ComputerArgumentType implements ArgumentType<ComputerArgumentType.ComputerSupplier> {
public final class ComputerArgumentType implements ArgumentType<ComputerSelector> {
private static final ComputerArgumentType INSTANCE = new ComputerArgumentType();
public static ComputerArgumentType oneComputer() {
return INSTANCE;
}
private static final List<String> EXAMPLES = List.of(
"0", "123", "@c[instance_id=123]"
);
public static ServerComputer getComputerArgument(CommandContext<CommandSourceStack> context, String name) throws CommandSyntaxException {
return context.getArgument(name, ComputerSupplier.class).unwrap(context.getSource());
public static ComputerArgumentType get() {
return INSTANCE;
}
private ComputerArgumentType() {
}
/**
* Extract a list of computers from a {@link CommandContext} argument.
*
* @param context The current command context.
* @param name The name of the argument.
* @return The found computer(s).
*/
public static List<ServerComputer> getMany(CommandContext<CommandSourceStack> context, String name) {
return context.getArgument(name, ComputerSelector.class).find(context.getSource()).toList();
}
/**
* Extract a single computer from a {@link CommandContext} argument.
*
* @param context The current command context.
* @param name The name of the argument.
* @return The found computer.
* @throws CommandSyntaxException If exactly one computer could not be found.
*/
public static ServerComputer getOne(CommandContext<CommandSourceStack> context, String name) throws CommandSyntaxException {
return context.getArgument(name, ComputerSelector.class).findOne(context.getSource());
}
@Override
public ComputerSupplier parse(StringReader reader) throws CommandSyntaxException {
var start = reader.getCursor();
var supplier = ComputersArgumentType.someComputers().parse(reader);
var selector = reader.getString().substring(start, reader.getCursor());
return s -> {
var computers = supplier.unwrap(s);
if (computers.size() == 1) return computers.iterator().next();
var builder = new StringBuilder();
var first = true;
for (var computer : computers) {
if (first) {
first = false;
} else {
builder.append(", ");
}
builder.append(computer.getInstanceID());
}
// We have an incorrect number of computers: reset and throw an error
reader.setCursor(start);
throw COMPUTER_ARG_MANY.createWithContext(reader, selector, builder.toString());
};
public ComputerSelector parse(StringReader reader) throws CommandSyntaxException {
return ComputerSelector.parse(reader);
}
@Override
public <S> CompletableFuture<Suggestions> listSuggestions(CommandContext<S> context, SuggestionsBuilder builder) {
return ComputersArgumentType.someComputers().listSuggestions(context, builder);
return ComputerSelector.suggest(context, builder);
}
@Override
public Collection<String> getExamples() {
return ComputersArgumentType.someComputers().getExamples();
}
@FunctionalInterface
public interface ComputerSupplier {
ServerComputer unwrap(CommandSourceStack source) throws CommandSyntaxException;
return EXAMPLES;
}
}

View File

@ -0,0 +1,374 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.command.arguments;
import com.mojang.brigadier.Message;
import com.mojang.brigadier.StringReader;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.computer.core.ServerContext;
import net.minecraft.advancements.critereon.MinMaxBounds;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.SharedSuggestionProvider;
import net.minecraft.network.chat.Component;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import javax.annotation.Nullable;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static dan200.computercraft.shared.command.CommandUtils.suggestOnServer;
import static dan200.computercraft.shared.command.Exceptions.*;
import static dan200.computercraft.shared.command.arguments.ArgumentParserUtils.consume;
public record ComputerSelector(
String selector,
OptionalInt instanceId,
OptionalInt computerId,
@Nullable String label,
@Nullable ComputerFamily family,
@Nullable AABB bounds,
@Nullable MinMaxBounds.Doubles range
) {
private static final ComputerSelector all = new ComputerSelector("@c[]", OptionalInt.empty(), OptionalInt.empty(), null, null, null, null);
/**
* A {@link ComputerSelector} which matches all computers.
*
* @return A {@link ComputerSelector} instance.
*/
public static ComputerSelector all() {
return all;
}
/**
* Find all computers matching this selector.
*
* @param source The source requesting these computers.
* @return The stream of matching computers.
*/
public Stream<ServerComputer> find(CommandSourceStack source) {
var context = ServerContext.get(source.getServer());
if (instanceId.isPresent()) {
var computer = context.registry().get(instanceId.getAsInt());
return computer != null && matches(source, computer) ? Stream.of(computer) : Stream.of();
}
return context.registry().getComputers().stream().filter(c -> matches(source, c));
}
/**
* Find exactly one computer which matches this selector.
*
* @param source The source requesting this computer.
* @return The computer.
* @throws CommandSyntaxException If no or multiple computers could be found.
*/
public ServerComputer findOne(CommandSourceStack source) throws CommandSyntaxException {
var computers = find(source).toList();
if (computers.isEmpty()) throw COMPUTER_ARG_NONE.create(selector);
if (computers.size() == 1) return computers.iterator().next();
var builder = new StringBuilder();
var first = true;
for (var computer : computers) {
if (first) {
first = false;
} else {
builder.append(", ");
}
builder.append(computer.getInstanceID());
}
// We have an incorrect number of computers: throw an error
throw COMPUTER_ARG_MANY.create(selector, builder.toString());
}
/**
* Determine if this selector matches a given computer.
*
* @param source The command source, used for distance comparisons.
* @param computer The computer to check.
* @return If this computer is matched by the selector.
*/
public boolean matches(CommandSourceStack source, ServerComputer computer) {
return (instanceId().isEmpty() || computer.getInstanceID() == instanceId().getAsInt())
&& (computerId().isEmpty() || computer.getID() == computerId().getAsInt())
&& (label == null || Objects.equals(computer.getLabel(), label))
&& (family == null || computer.getFamily() == family)
&& (bounds == null || (source.getLevel() == computer.getLevel() && bounds.contains(Vec3.atCenterOf(computer.getPosition()))))
&& (range == null || (source.getLevel() == computer.getLevel() && range.matchesSqr(source.getPosition().distanceToSqr(Vec3.atCenterOf(computer.getPosition())))));
}
/**
* Parse an input string.
*
* @param reader The reader to parse from.
* @return The parsed selector.
* @throws CommandSyntaxException If the selector was incomplete or malformed.
*/
public static ComputerSelector parse(StringReader reader) throws CommandSyntaxException {
var start = reader.getCursor();
var builder = new Builder();
if (consume(reader, "@c[")) {
parseSelector(builder, reader);
} else {
var kind = reader.peek();
if (kind == '@') {
reader.skip();
builder.label = reader.readString();
} else if (kind == '~') {
reader.skip();
builder.family = parseFamily(reader);
} else if (kind == '#') {
reader.skip();
builder.computerId = OptionalInt.of(reader.readInt());
} else {
builder.instanceId = OptionalInt.of(reader.readInt());
}
}
var selector = reader.getString().substring(start, reader.getCursor());
return new ComputerSelector(selector, builder.instanceId, builder.computerId, builder.label, builder.family, builder.bounds, builder.range);
}
private static void parseSelector(Builder builder, StringReader reader) throws CommandSyntaxException {
Set<Option> seenOptions = new HashSet<>();
while (true) {
reader.skipWhitespace();
if (!reader.canRead()) throw ERROR_EXPECTED_END_OF_OPTIONS.createWithContext(reader);
if (consume(reader, ']')) break;
// Read the option and validate it.
var option = parseOption(reader, seenOptions);
reader.skipWhitespace();
if (!consume(reader, '=')) throw ERROR_EXPECTED_OPTION_VALUE.createWithContext(reader, option.name());
reader.skipWhitespace();
option.parser.parse(reader, builder);
reader.skipWhitespace();
if (consume(reader, ']')) break;
if (!consume(reader, ',')) throw ERROR_EXPECTED_END_OF_OPTIONS.createWithContext(reader);
}
}
private static Option parseOption(StringReader reader, Set<Option> seen) throws CommandSyntaxException {
var start = reader.getCursor();
var name = reader.readUnquotedString();
var option = options.get(name);
if (option == null) {
reader.setCursor(start);
throw ERROR_UNKNOWN_OPTION.createWithContext(reader, name);
} else if (!seen.add(option)) {
throw ERROR_INAPPLICABLE_OPTION.createWithContext(reader, name);
}
return option;
}
private static ComputerFamily parseFamily(StringReader reader) throws CommandSyntaxException {
var start = reader.getCursor();
var name = reader.readUnquotedString();
var family = Arrays.stream(ComputerFamily.values()).filter(x -> x.name().equalsIgnoreCase(name)).findFirst().orElse(null);
if (family == null) {
reader.setCursor(start);
throw UNKNOWN_FAMILY.createWithContext(reader, name);
}
return family;
}
/**
* Suggest completions for a selector argument.
*
* @param context The current command context.
* @param builder The builder containing the current input.
* @return The possible suggestions.
*/
public static CompletableFuture<Suggestions> suggest(CommandContext<?> context, SuggestionsBuilder builder) {
var remaining = builder.getRemaining();
if (remaining.startsWith("@")) {
var reader = new StringReader(builder.getInput());
reader.setCursor(builder.getStart());
return suggestSelector(context, reader);
} else if (remaining.startsWith("#")) {
return suggestComputers(c -> "#" + c.getID()).suggest(context, builder);
} else {
return suggestComputers(c -> Integer.toString(c.getInstanceID())).suggest(context, builder);
}
}
private static CompletableFuture<Suggestions> suggestSelector(CommandContext<?> context, StringReader reader) {
Set<Option> seenOptions = new HashSet<>();
var builder = new Builder();
if (!consume(reader, "@c[")) return suggestions(reader).suggest("@c[").buildFuture();
while (true) {
reader.skipWhitespace();
if (!reader.canRead()) return suggestOptions(reader);
if (consume(reader, ']')) break;
// Read the option and validate it.
Option option;
try {
option = parseOption(reader, seenOptions);
} catch (CommandSyntaxException e) {
return suggestOptions(reader);
}
reader.skipWhitespace();
if (!consume(reader, '=')) return suggestions(reader).suggest("=").buildFuture();
reader.skipWhitespace();
try {
option.parser.parse(reader, builder);
} catch (CommandSyntaxException e) {
return option.suggest.suggest(context, suggestions(reader));
}
reader.skipWhitespace();
if (consume(reader, ']')) break;
if (!consume(reader, ',')) return suggestions(reader).suggest(",").buildFuture();
}
return Suggestions.empty();
}
private static CompletableFuture<Suggestions> suggestOptions(StringReader reader) {
return SharedSuggestionProvider.suggest(options().values(), suggestions(reader), Option::name, Option::tooltip);
}
private static SuggestionsBuilder suggestions(StringReader reader) {
return new SuggestionsBuilder(reader.getString(), reader.getCursor());
}
private static final class Builder {
private OptionalInt instanceId = OptionalInt.empty();
private OptionalInt computerId = OptionalInt.empty();
private @Nullable String label;
private @Nullable ComputerFamily family;
private @Nullable AABB bounds;
private @Nullable MinMaxBounds.Doubles range;
}
private static final Map<String, Option> options;
/**
* Get a map of individual selector options.
*
* @return The available options.
*/
public static Map<String, Option> options() {
return options;
}
static {
var optionList = new Option[]{
new Option(
"instance",
(reader, builder) -> builder.instanceId = OptionalInt.of(reader.readInt()),
suggestComputers(c -> Integer.toString(c.getInstanceID()))
),
new Option(
"id",
(reader, builder) -> builder.computerId = OptionalInt.of(reader.readInt()),
suggestComputers(c -> Integer.toString(c.getID()))
),
new Option(
"label",
(reader, builder) -> builder.label = reader.readQuotedString(),
suggestComputers(ServerComputer::getLabel)
),
new Option(
"family",
(reader, builder) -> builder.family = parseFamily(reader),
(source, builder) -> SharedSuggestionProvider.suggest(Arrays.stream(ComputerFamily.values()).map(x -> x.name().toLowerCase(Locale.ROOT)), builder)
),
new Option(
"distance",
(reader, builder) -> builder.range = MinMaxBounds.Doubles.fromReader(reader),
(source, builder) -> Suggestions.empty()
),
};
options = Arrays.stream(optionList).collect(Collectors.toUnmodifiableMap(Option::name, x -> x));
}
/**
* A single option to filter a computer by.
*/
public static final class Option {
private final String name;
private final Parser parser;
private final SuggestionProvider suggest;
private final String translationKey;
private final Message tooltip;
Option(String name, Parser parser, SuggestionProvider suggest) {
this.name = name;
this.parser = parser;
this.suggest = suggest;
tooltip = Component.translatable(translationKey = "argument.computercraft.computer." + name);
}
/**
* The name of this selector.
*
* @return The selector's name.
*/
public String name() {
return name;
}
public Message tooltip() {
return tooltip;
}
/**
* The translation key for this selector.
*
* @return The selector's translation key.
*/
public String translationKey() {
return translationKey;
}
}
private interface Parser {
void parse(StringReader reader, Builder builder) throws CommandSyntaxException;
}
private interface SuggestionProvider {
CompletableFuture<Suggestions> suggest(CommandContext<?> source, SuggestionsBuilder builder);
}
private static SuggestionProvider suggestComputers(Function<ServerComputer, String> renderer) {
return (anyContext, builder) -> suggestOnServer(anyContext, context -> {
var remaining = builder.getRemaining();
for (var computer : ServerContext.get(context.getSource().getServer()).registry().getComputers()) {
var converted = renderer.apply(computer);
if (converted != null && converted.startsWith(remaining)) {
builder.suggest(converted);
}
}
return builder.buildFuture();
});
}
}

View File

@ -1,188 +0,0 @@
// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.command.arguments;
import com.google.gson.JsonObject;
import com.mojang.brigadier.StringReader;
import com.mojang.brigadier.arguments.ArgumentType;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.computer.core.ServerContext;
import net.minecraft.commands.CommandBuildContext;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.synchronization.ArgumentTypeInfo;
import net.minecraft.network.FriendlyByteBuf;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import java.util.function.Predicate;
import static dan200.computercraft.shared.command.CommandUtils.suggest;
import static dan200.computercraft.shared.command.CommandUtils.suggestOnServer;
import static dan200.computercraft.shared.command.Exceptions.COMPUTER_ARG_NONE;
public final class ComputersArgumentType implements ArgumentType<ComputersArgumentType.ComputersSupplier> {
private static final ComputersArgumentType MANY = new ComputersArgumentType(false);
private static final ComputersArgumentType SOME = new ComputersArgumentType(true);
private static final List<String> EXAMPLES = List.of(
"0", "#0", "@Label", "~Advanced"
);
public static ComputersArgumentType manyComputers() {
return MANY;
}
public static ComputersArgumentType someComputers() {
return SOME;
}
public static Collection<ServerComputer> getComputersArgument(CommandContext<CommandSourceStack> context, String name) throws CommandSyntaxException {
return context.getArgument(name, ComputersSupplier.class).unwrap(context.getSource());
}
private final boolean requireSome;
private ComputersArgumentType(boolean requireSome) {
this.requireSome = requireSome;
}
@Override
public ComputersSupplier parse(StringReader reader) throws CommandSyntaxException {
var start = reader.getCursor();
var kind = reader.peek();
ComputersSupplier computers;
if (kind == '@') {
reader.skip();
var label = reader.readUnquotedString();
computers = getComputers(x -> Objects.equals(label, x.getLabel()));
} else if (kind == '~') {
reader.skip();
var family = reader.readUnquotedString();
computers = getComputers(x -> x.getFamily().name().equalsIgnoreCase(family));
} else if (kind == '#') {
reader.skip();
var id = reader.readInt();
computers = getComputers(x -> x.getID() == id);
} else {
var instance = reader.readInt();
computers = s -> {
var computer = ServerContext.get(s.getServer()).registry().get(instance);
return computer == null ? List.of() : List.of(computer);
};
}
if (requireSome) {
var selector = reader.getString().substring(start, reader.getCursor());
return source -> {
var matched = computers.unwrap(source);
if (matched.isEmpty()) throw COMPUTER_ARG_NONE.create(selector);
return matched;
};
} else {
return computers;
}
}
@Override
public <S> CompletableFuture<Suggestions> listSuggestions(CommandContext<S> context, SuggestionsBuilder builder) {
var remaining = builder.getRemaining();
// We can run this one on the client, for obvious reasons.
if (remaining.startsWith("~")) {
return suggest(builder, ComputerFamily.values(), x -> "~" + x.name());
}
// Verify we've a command source and we're running on the server
return suggestOnServer(context, s -> {
if (remaining.startsWith("@")) {
suggestComputers(s.getSource(), builder, remaining, x -> {
var label = x.getLabel();
return label == null ? null : "@" + label;
});
} else if (remaining.startsWith("#")) {
suggestComputers(s.getSource(), builder, remaining, c -> "#" + c.getID());
} else {
suggestComputers(s.getSource(), builder, remaining, c -> Integer.toString(c.getInstanceID()));
}
return builder.buildFuture();
});
}
@Override
public Collection<String> getExamples() {
return EXAMPLES;
}
private static void suggestComputers(CommandSourceStack source, SuggestionsBuilder builder, String remaining, Function<ServerComputer, String> renderer) {
remaining = remaining.toLowerCase(Locale.ROOT);
for (var computer : ServerContext.get(source.getServer()).registry().getComputers()) {
var converted = renderer.apply(computer);
if (converted != null && converted.toLowerCase(Locale.ROOT).startsWith(remaining)) {
builder.suggest(converted);
}
}
}
private static ComputersSupplier getComputers(Predicate<ServerComputer> predicate) {
return s -> ServerContext.get(s.getServer()).registry()
.getComputers()
.stream()
.filter(predicate)
.toList();
}
public static class Info implements ArgumentTypeInfo<ComputersArgumentType, Template> {
@Override
public void serializeToNetwork(ComputersArgumentType.Template arg, FriendlyByteBuf buf) {
buf.writeBoolean(arg.requireSome());
}
@Override
public ComputersArgumentType.Template deserializeFromNetwork(FriendlyByteBuf buf) {
var requiresSome = buf.readBoolean();
return new ComputersArgumentType.Template(this, requiresSome);
}
@Override
public void serializeToJson(ComputersArgumentType.Template arg, JsonObject json) {
json.addProperty("requireSome", arg.requireSome);
}
@Override
public ComputersArgumentType.Template unpack(ComputersArgumentType argumentType) {
return new ComputersArgumentType.Template(this, argumentType.requireSome);
}
}
public record Template(Info info, boolean requireSome) implements ArgumentTypeInfo.Template<ComputersArgumentType> {
@Override
public ComputersArgumentType instantiate(CommandBuildContext context) {
return requireSome ? SOME : MANY;
}
@Override
public Info type() {
return info;
}
}
@FunctionalInterface
public interface ComputersSupplier {
Collection<ServerComputer> unwrap(CommandSourceStack source) throws CommandSyntaxException;
}
public static Set<ServerComputer> unwrap(CommandSourceStack source, Collection<ComputersSupplier> suppliers) throws CommandSyntaxException {
Set<ServerComputer> computers = new HashSet<>();
for (var supplier : suppliers) computers.addAll(supplier.unwrap(source));
return computers;
}
}

View File

@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.command.arguments;
import com.mojang.brigadier.StringReader;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.context.StringRange;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.brigadier.suggestion.Suggestion;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import dan200.computercraft.shared.command.Exceptions;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.test.core.ReplaceUnderscoresDisplayNameGenerator;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.List;
import java.util.Map;
import java.util.OptionalInt;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@DisplayNameGeneration(ReplaceUnderscoresDisplayNameGenerator.class)
class ComputerSelectorTest {
@ParameterizedTest(name = "{0}")
@MethodSource("getArgumentTestCases")
public void Parse_basic_inputs(String input, ComputerSelector expected) throws CommandSyntaxException {
assertEquals(expected, ComputerSelector.parse(new StringReader(input)));
}
public static Arguments[] getArgumentTestCases() {
return new Arguments[]{
// Legacy selectors
Arguments.of("@some_label", new ComputerSelector("@some_label", OptionalInt.empty(), OptionalInt.empty(), "some_label", null, null, null)),
Arguments.of("~normal", new ComputerSelector("~normal", OptionalInt.empty(), OptionalInt.empty(), null, ComputerFamily.NORMAL, null, null)),
Arguments.of("#123", new ComputerSelector("#123", OptionalInt.empty(), OptionalInt.of(123), null, null, null, null)),
Arguments.of("123", new ComputerSelector("123", OptionalInt.of(123), OptionalInt.empty(), null, null, null, null)),
// New selectors
Arguments.of("@c[]", new ComputerSelector("@c[]", OptionalInt.empty(), OptionalInt.empty(), null, null, null, null)),
Arguments.of("@c[instance=123]", new ComputerSelector("@c[instance=123]", OptionalInt.of(123), OptionalInt.empty(), null, null, null, null)),
Arguments.of("@c[id=123]", new ComputerSelector("@c[id=123]", OptionalInt.empty(), OptionalInt.of(123), null, null, null, null)),
Arguments.of("@c[label=\"foo\"]", new ComputerSelector("@c[label=\"foo\"]", OptionalInt.empty(), OptionalInt.empty(), "foo", null, null, null)),
Arguments.of("@c[family=normal]", new ComputerSelector("@c[family=normal]", OptionalInt.empty(), OptionalInt.empty(), null, ComputerFamily.NORMAL, null, null)),
// Complex selectors
Arguments.of("@c[ id = 123 , ]", new ComputerSelector("@c[ id = 123 , ]", OptionalInt.empty(), OptionalInt.of(123), null, null, null, null)),
Arguments.of("@c[id=123,family=normal]", new ComputerSelector("@c[id=123,family=normal]", OptionalInt.empty(), OptionalInt.of(123), null, ComputerFamily.NORMAL, null, null)),
};
}
@Test
public void Fails_on_repeated_options() {
var error = assertThrows(CommandSyntaxException.class, () -> ComputerSelector.parse(new StringReader("@c[id=1, id=2]")));
assertEquals(Exceptions.ERROR_INAPPLICABLE_OPTION, error.getType());
}
@Test
public void Complete_selector_components() {
assertEquals(List.of(new Suggestion(StringRange.between(0, 1), "@c[")), suggest("@"));
assertThat(suggest("@c["), hasItem(
new Suggestion(StringRange.at(3), "family", ComputerSelector.options().get("family").tooltip())
));
assertEquals(List.of(new Suggestion(StringRange.at(9), "=")), suggest("@c[family"));
assertEquals(List.of(new Suggestion(StringRange.at(16), ",")), suggest("@c[family=normal"));
}
@Test
public void Complete_selector_family() {
assertThat(suggest("@c[family="), containsInAnyOrder(
new Suggestion(StringRange.at(10), "normal"),
new Suggestion(StringRange.at(10), "advanced"),
new Suggestion(StringRange.at(10), "command")
));
assertThat(suggest("@c[family=n"), contains(
new Suggestion(StringRange.between(10, 11), "normal")
));
}
private List<Suggestion> suggest(String input) {
var context = new CommandContext<>(new Object(), "", Map.of(), null, null, List.of(), StringRange.at(0), null, null, false);
return ComputerSelector.suggest(context, new SuggestionsBuilder(input, 0)).getNow(null).getList();
}
}

View File

@ -1,8 +1,14 @@
{
"argument.computercraft.argument_expected": "Argument expected",
"argument.computercraft.computer.distance": "Distance to entity",
"argument.computercraft.computer.family": "Computer family",
"argument.computercraft.computer.id": "Computer ID",
"argument.computercraft.computer.instance": "Unique instance ID",
"argument.computercraft.computer.label": "Computer label",
"argument.computercraft.computer.many_matching": "Multiple computers matching '%s' (instances %s)",
"argument.computercraft.computer.no_matching": "No computers matching '%s'",
"argument.computercraft.tracking_field.no_field": "Unknown field '%s'",
"argument.computercraft.unknown_computer_family": "Unknown computer family '%s'",
"block.computercraft.cable": "Networking Cable",
"block.computercraft.computer_advanced": "Advanced Computer",
"block.computercraft.computer_command": "Command Computer",

View File

@ -1,8 +1,14 @@
{
"argument.computercraft.argument_expected": "Argument expected",
"argument.computercraft.computer.distance": "Distance to entity",
"argument.computercraft.computer.family": "Computer family",
"argument.computercraft.computer.id": "Computer ID",
"argument.computercraft.computer.instance": "Unique instance ID",
"argument.computercraft.computer.label": "Computer label",
"argument.computercraft.computer.many_matching": "Multiple computers matching '%s' (instances %s)",
"argument.computercraft.computer.no_matching": "No computers matching '%s'",
"argument.computercraft.tracking_field.no_field": "Unknown field '%s'",
"argument.computercraft.unknown_computer_family": "Unknown computer family '%s'",
"block.computercraft.cable": "Networking Cable",
"block.computercraft.computer_advanced": "Advanced Computer",
"block.computercraft.computer_command": "Command Computer",