diff --git a/src/main/java/dan200/computercraft/ComputerCraft.java b/src/main/java/dan200/computercraft/ComputerCraft.java index d0b287ca8..2c7f6df23 100644 --- a/src/main/java/dan200/computercraft/ComputerCraft.java +++ b/src/main/java/dan200/computercraft/ComputerCraft.java @@ -25,6 +25,7 @@ import dan200.computercraft.core.filesystem.FileMount; import dan200.computercraft.core.filesystem.JarMount; import dan200.computercraft.core.terminal.Terminal; import dan200.computercraft.shared.command.CommandComputer; +import dan200.computercraft.shared.command.CommandComputerCraft; import dan200.computercraft.shared.common.DefaultBundledRedstoneProvider; import dan200.computercraft.shared.computer.blocks.BlockCommandComputer; import dan200.computercraft.shared.computer.blocks.BlockComputer; @@ -427,6 +428,7 @@ public class ComputerCraft public void onServerStarting( FMLServerStartingEvent event ) { event.registerServerCommand( new CommandComputer() ); + event.registerServerCommand( new CommandComputerCraft() ); } @Mod.EventHandler diff --git a/src/main/java/dan200/computercraft/shared/command/CommandComputerCraft.java b/src/main/java/dan200/computercraft/shared/command/CommandComputerCraft.java new file mode 100644 index 000000000..6c11ecd88 --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/CommandComputerCraft.java @@ -0,0 +1,256 @@ +package dan200.computercraft.shared.command; + +import com.google.common.collect.Lists; +import dan200.computercraft.ComputerCraft; +import dan200.computercraft.api.peripheral.IPeripheral; +import dan200.computercraft.core.computer.Computer; +import dan200.computercraft.shared.command.framework.*; +import dan200.computercraft.shared.computer.core.ServerComputer; +import net.minecraft.command.CommandException; +import net.minecraft.command.ICommandSender; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.world.World; + +import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.List; + +import static dan200.computercraft.shared.command.framework.ChatHelpers.*; + +public final class CommandComputerCraft extends CommandDelegate +{ + public CommandComputerCraft() + { + super( create() ); + } + + private static ISubCommand create() + { + CommandRoot root = new CommandRoot( + "computercraft", "Various commands for controlling computers.", + "The /computercraft command provides various debugging and administrator tools for controlling and " + + "interacting with computers." + ); + + root.register( new SubCommandBase( + "dump", "[id]", "Display the status of computers.", UserLevel.OWNER_OP, + "Display the status of all computers or specific information about one computer. You can either specify the computer's instance " + + "id (e.g. 123) or computer id (e.g #123)." + ) + { + @Override + public void execute( @Nonnull CommandContext context, @Nonnull List arguments ) throws CommandException + { + if( arguments.size() == 0 ) + { + TextTable table = new TextTable( "Instance", "Id", "On", "Position" ); + + int max = 50; + for( ServerComputer computer : ComputerCraft.serverComputerRegistry.getComputers() ) + { + table.addRow( + linkComputer( computer ), + text( Integer.toString( computer.getID() ) ), + bool( computer.isOn() ), + linkPosition( context, computer ) + ); + + if( max-- < 0 ) break; + } + + table.displayTo( context.getSender() ); + } + else if( arguments.size() == 1 ) + { + ServerComputer computer = ComputerSelector.getComputer( arguments.get( 0 ) ); + + TextTable table = new TextTable(); + table.addRow( header( "Instance" ), text( Integer.toString( computer.getInstanceID() ) ) ); + table.addRow( header( "Id" ), text( Integer.toString( computer.getID() ) ) ); + table.addRow( header( "Label" ), text( computer.getLabel() ) ); + table.addRow( header( "On" ), bool( computer.isOn() ) ); + table.addRow( header( "Position" ), linkPosition( context, computer ) ); + table.addRow( header( "Family" ), text( computer.getFamily().toString() ) ); + + for( int i = 0; i < 6; i++ ) + { + IPeripheral peripheral = computer.getPeripheral( i ); + if( peripheral != null ) + { + table.addRow( header( "Peripheral " + Computer.s_sideNames[ i ] ), text( peripheral.getType() ) ); + } + } + + table.displayTo( context.getSender() ); + } + else + { + throw new CommandException( context.getFullUsage() ); + } + } + + @Nonnull + @Override + public List getCompletion( @Nonnull CommandContext context, @Nonnull List arguments ) + { + return arguments.size() == 1 + ? ComputerSelector.completeComputer( arguments.get( 0 ) ) + : Collections.emptyList(); + } + } ); + + root.register( new SubCommandBase( + "shutdown", "[ids...]", "Shutdown computers remotely.", UserLevel.OWNER_OP, + "Shutdown the listed computers or all if none are specified. You can either specify the computer's instance " + + "id (e.g. 123) or computer id (e.g #123)." + ) + { + @Override + public void execute( @Nonnull CommandContext context, @Nonnull List arguments ) throws CommandException + { + List computers = Lists.newArrayList(); + if( arguments.size() > 0 ) + { + for( String arg : arguments ) + { + computers.add( ComputerSelector.getComputer( arg ) ); + } + } + else + { + computers.addAll( ComputerCraft.serverComputerRegistry.getComputers() ); + } + + int shutdown = 0; + for( ServerComputer computer : computers ) + { + if( computer.isOn() ) shutdown++; + computer.unload(); + } + context.getSender().sendMessage( text( "Shutdown " + shutdown + " / " + computers.size() + " computers" ) ); + } + + @Nonnull + @Override + public List getCompletion( @Nonnull CommandContext context, @Nonnull List arguments ) + { + return arguments.size() == 0 + ? Collections.emptyList() + : ComputerSelector.completeComputer( arguments.get( arguments.size() - 1 ) ); + } + } ); + + root.register( new SubCommandBase( + "tp", "", "Teleport to a specific computer.", UserLevel.OP, + "Teleport to the location of a computer. You can either specify the computer's instance " + + "id (e.g. 123) or computer id (e.g #123)." + ) + { + @Override + public void execute( @Nonnull CommandContext context, @Nonnull List arguments ) throws CommandException + { + if( arguments.size() != 1 ) throw new CommandException( context.getFullUsage() ); + + ServerComputer computer = ComputerSelector.getComputer( arguments.get( 0 ) ); + World world = computer.getWorld(); + BlockPos pos = computer.getPosition(); + + if( world == null || pos == null ) throw new CommandException( "Cannot locate computer in world" ); + + ICommandSender sender = context.getSender(); + if( !(sender instanceof Entity) ) throw new CommandException( "Sender is not an entity" ); + + if( sender instanceof EntityPlayerMP ) + { + EntityPlayerMP entity = (EntityPlayerMP) sender; + if( entity.getEntityWorld() != world ) + { + context.getServer().getPlayerList().changePlayerDimension( entity, world.provider.getDimension() ); + } + + entity.setPositionAndUpdate( pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5 ); + } + else + { + Entity entity = (Entity) sender; + if( entity.getEntityWorld() != world ) + { + entity.changeDimension( world.provider.getDimension() ); + } + + entity.setLocationAndAngles( + pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5, + entity.rotationYaw, entity.rotationPitch + ); + } + } + + @Nonnull + @Override + public List getCompletion( @Nonnull CommandContext context, @Nonnull List arguments ) + { + return arguments.size() == 1 + ? ComputerSelector.completeComputer( arguments.get( 0 ) ) + : Collections.emptyList(); + } + } ); + + root.register(new SubCommandBase( + "view", "", "View the terminal of a computer.", UserLevel.OP, + "Open the terminal of a computer, allowing remote control of a computer. This does not provide access to " + + "turtle's inventories. You can either specify the computer's instance id (e.g. 123) or computer id (e.g #123)." + ) { + @Override + public void execute(@Nonnull CommandContext context, @Nonnull List arguments) throws CommandException { + if (arguments.size() != 1) throw new CommandException(context.getFullUsage()); + + ICommandSender sender = context.getSender(); + if (!(sender instanceof EntityPlayerMP)) { + throw new CommandException("Cannot open terminal for non-player"); + } + + ServerComputer computer = ComputerSelector.getComputer(arguments.get(0)); + ComputerCraft.openComputerGUI( (EntityPlayerMP) sender, computer ); + } + + @Nonnull + @Override + public List getCompletion(@Nonnull CommandContext context, @Nonnull List arguments) { + return arguments.size() == 1 + ? ComputerSelector.completeComputer( arguments.get( 0 ) ) + : Collections.emptyList(); + } + }); + + + return root; + } + + private static ITextComponent linkComputer( ServerComputer computer ) + { + return link( + text( Integer.toString( computer.getInstanceID() ) ), + "/computercraft dump " + computer.getInstanceID(), + "View more info about this computer" + ); + } + + private static ITextComponent linkPosition( CommandContext context, ServerComputer computer ) + { + if( UserLevel.OP.canExecute( context ) ) + { + return link( + position( computer.getPosition() ), + "/computercraft tp " + computer.getInstanceID(), + "Teleport to this computer" + ); + } + else + { + return position( computer.getPosition() ); + } + } +} diff --git a/src/main/java/dan200/computercraft/shared/command/ComputerSelector.java b/src/main/java/dan200/computercraft/shared/command/ComputerSelector.java new file mode 100644 index 000000000..388fd4413 --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/ComputerSelector.java @@ -0,0 +1,126 @@ +package dan200.computercraft.shared.command; + +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import dan200.computercraft.ComputerCraft; +import dan200.computercraft.shared.computer.core.ServerComputer; +import net.minecraft.command.CommandException; + +import java.util.List; +import java.util.Set; + +public final class ComputerSelector +{ + public static ServerComputer getComputer( String selector ) throws CommandException + { + if( selector.length() > 0 && selector.charAt( 0 ) == '#' ) + { + selector = selector.substring( 1 ); + + int id; + try + { + id = Integer.parseInt( selector ); + } + catch( NumberFormatException e ) + { + throw new CommandException( "'" + selector + "' is not a valid number" ); + } + + // We copy it to prevent concurrent modifications. + List computers = Lists.newArrayList( ComputerCraft.serverComputerRegistry.getComputers() ); + List candidates = Lists.newArrayList(); + for( ServerComputer searchComputer : computers ) + { + if( searchComputer.getID() == id ) + { + candidates.add( searchComputer ); + } + } + + if( candidates.size() == 0 ) + { + throw new CommandException( "No such computer for id " + id ); + } + else if( candidates.size() == 1 ) + { + return candidates.get( 0 ); + } + else + { + StringBuilder builder = new StringBuilder( "Multiple computers with id " ) + .append( id ).append( " (instances " ); + + boolean first = true; + for( ServerComputer computer : candidates ) + { + if( first ) + { + first = false; + } + else + { + builder.append( ", " ); + } + + builder.append( computer.getInstanceID() ); + } + + builder.append( ")" ); + + throw new CommandException( builder.toString() ); + } + } + else + { + int instance; + try + { + instance = Integer.parseInt( selector ); + } + catch( NumberFormatException e ) + { + throw new CommandException( "'" + selector + "' is not a valid number" ); + } + + ServerComputer computer = ComputerCraft.serverComputerRegistry.get( instance ); + if( computer == null ) + { + throw new CommandException( "No such computer for instance id " + instance ); + } + else + { + return computer; + } + } + } + + public static List completeComputer( String selector ) + { + Set options = Sets.newHashSet(); + + // We copy it to prevent concurrent modifications. + List computers = Lists.newArrayList( ComputerCraft.serverComputerRegistry.getComputers() ); + + if( selector.length() > 0 && selector.charAt( 0 ) == '#' ) + { + selector = selector.substring( 1 ); + + for( ServerComputer computer : computers ) + { + String id = Integer.toString( computer.getID() ); + if( id.startsWith( selector ) ) options.add( "#" + id ); + } + } + else + { + for( ServerComputer computer : computers ) + { + String id = Integer.toString( computer.getInstanceID() ); + if( id.startsWith( selector ) ) options.add( id ); + } + } + + return Lists.newArrayList( options ); + } +} diff --git a/src/main/java/dan200/computercraft/shared/command/ContainerViewComputer.java b/src/main/java/dan200/computercraft/shared/command/ContainerViewComputer.java new file mode 100644 index 000000000..26441c4b6 --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/ContainerViewComputer.java @@ -0,0 +1,64 @@ +package dan200.computercraft.shared.command; + +import dan200.computercraft.ComputerCraft; +import dan200.computercraft.shared.computer.core.ComputerFamily; +import dan200.computercraft.shared.computer.core.IComputer; +import dan200.computercraft.shared.computer.core.IContainerComputer; +import dan200.computercraft.shared.computer.core.ServerComputer; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.inventory.Container; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.text.TextComponentTranslation; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class ContainerViewComputer extends Container implements IContainerComputer +{ + private final IComputer computer; + + public ContainerViewComputer( IComputer computer ) + { + this.computer = computer; + } + + @Nullable + @Override + public IComputer getComputer() + { + return computer; + } + + @Override + public boolean canInteractWith( @Nonnull EntityPlayer player ) + { + if( computer instanceof ServerComputer ) + { + ServerComputer serverComputer = (ServerComputer) computer; + + // If this computer no longer exists then discard it. + if( ComputerCraft.serverComputerRegistry.get( serverComputer.getInstanceID() ) != serverComputer ) + { + return false; + } + + // If we're a command computer then ensure we're in creative + if( serverComputer.getFamily() == ComputerFamily.Command ) + { + MinecraftServer server = player.getServer(); + if( server == null || !server.isCommandBlockEnabled() ) + { + player.sendMessage( new TextComponentTranslation( "advMode.notEnabled" ) ); + return false; + } + else if( !ComputerCraft.canPlayerUseCommands( player ) || !player.capabilities.isCreativeMode ) + { + player.sendMessage( new TextComponentTranslation( "advMode.notAllowed" ) ); + return false; + } + } + } + + return true; + } +} diff --git a/src/main/java/dan200/computercraft/shared/command/framework/ChatHelpers.java b/src/main/java/dan200/computercraft/shared/command/framework/ChatHelpers.java new file mode 100644 index 000000000..9016d752d --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/framework/ChatHelpers.java @@ -0,0 +1,117 @@ +package dan200.computercraft.shared.command.framework; + +import com.google.common.base.Strings; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.Style; +import net.minecraft.util.text.TextComponentString; +import net.minecraft.util.text.TextFormatting; +import net.minecraft.util.text.event.ClickEvent; +import net.minecraft.util.text.event.HoverEvent; + +/** + * Various helpers for building chat messages + */ +public final class ChatHelpers +{ + private static final TextFormatting HEADER = TextFormatting.LIGHT_PURPLE; + private static final TextFormatting SYNOPSIS = TextFormatting.AQUA; + private static final TextFormatting NAME = TextFormatting.GREEN; + + public static ITextComponent coloured( String text, TextFormatting colour ) + { + ITextComponent component = new TextComponentString( text == null ? "" : text ); + component.getStyle().setColor( colour ); + return component; + } + + public static ITextComponent text( String text ) + { + return new TextComponentString( text == null ? "" : text ); + } + + public static ITextComponent list( ITextComponent... children ) + { + ITextComponent component = new TextComponentString( "" ); + for( ITextComponent child : children ) + { + component.appendSibling( child ); + } + return component; + } + + public static ITextComponent getHelp( CommandContext context, ISubCommand command, String prefix ) + { + ITextComponent output = new TextComponentString( "" ) + .appendSibling( coloured( "/" + prefix + " " + command.getUsage( context ), HEADER ) ) + .appendText( " " ) + .appendSibling( coloured( command.getSynopsis(), SYNOPSIS ) ); + + String desc = command.getDescription(); + if( !Strings.isNullOrEmpty( desc ) ) output.appendText( "\n" + desc ); + + if( command instanceof CommandRoot ) + { + for( ISubCommand subCommand : ((CommandRoot) command).getSubCommands().values() ) + { + if( !subCommand.checkPermission( context ) ) continue; + + output.appendText( "\n" ); + + ITextComponent component = coloured( subCommand.getName(), NAME ); + component.getStyle().setClickEvent( new ClickEvent( + ClickEvent.Action.SUGGEST_COMMAND, + "/" + prefix + " " + subCommand.getName() + ) ); + output.appendSibling( component ); + + output.appendText( " - " + subCommand.getSynopsis() ); + } + } + + return output; + } + + public static ITextComponent position( BlockPos pos ) + { + if( pos == null ) return text( "" ); + return formatted( "%d, %d, %d", pos.getX(), pos.getY(), pos.getZ() ); + } + + public static ITextComponent bool( boolean value ) + { + if( value ) + { + ITextComponent component = new TextComponentString( "Y" ); + component.getStyle().setColor( TextFormatting.GREEN ); + return component; + } + else + { + ITextComponent component = new TextComponentString( "N" ); + component.getStyle().setColor( TextFormatting.RED ); + return component; + } + } + + public static ITextComponent formatted( String format, Object... args ) + { + return new TextComponentString( String.format( format, args ) ); + } + + public static ITextComponent link( ITextComponent component, String command, String toolTip ) + { + Style style = component.getStyle(); + + if( style.getColor() == null ) style.setColor( TextFormatting.YELLOW ); + style.setClickEvent( new ClickEvent( ClickEvent.Action.RUN_COMMAND, command ) ); + style.setHoverEvent( new HoverEvent( HoverEvent.Action.SHOW_TEXT, new TextComponentString( toolTip ) ) ); + + return component; + } + + public static ITextComponent header( String text ) + { + return coloured( text, HEADER ); + } +} diff --git a/src/main/java/dan200/computercraft/shared/command/framework/CommandContext.java b/src/main/java/dan200/computercraft/shared/command/framework/CommandContext.java new file mode 100644 index 000000000..b8979d6eb --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/framework/CommandContext.java @@ -0,0 +1,93 @@ +package dan200.computercraft.shared.command.framework; + +import com.google.common.collect.Lists; +import net.minecraft.command.ICommandSender; +import net.minecraft.server.MinecraftServer; + +import java.util.Collections; +import java.util.List; + +/** + * Represents the way a command was invoked, including the command sender, the current server and + * the "path" to this command. + */ +public final class CommandContext +{ + private final MinecraftServer server; + private final ICommandSender sender; + private final List path; + + public CommandContext( MinecraftServer server, ICommandSender sender, ISubCommand initial ) + { + this.server = server; + this.sender = sender; + this.path = Collections.singletonList( initial ); + } + + private CommandContext( MinecraftServer server, ICommandSender sender, List path ) + { + this.server = server; + this.sender = sender; + this.path = path; + } + + public CommandContext enter( ISubCommand child ) + { + List newPath = Lists.newArrayListWithExpectedSize( path.size() + 1 ); + newPath.addAll( path ); + newPath.add( child ); + return new CommandContext( server, sender, newPath ); + } + + public CommandContext parent() + { + if( path.size() == 1 ) throw new IllegalStateException( "No parent command" ); + return new CommandContext( server, sender, path.subList( 0, path.size() - 1 ) ); + } + + public String getFullPath() + { + StringBuilder out = new StringBuilder(); + boolean first = true; + for( ISubCommand command : path ) + { + if( first ) + { + first = false; + } + else + { + out.append( ' ' ); + } + + out.append( command.getName() ); + } + + return out.toString(); + } + + public String getFullUsage() + { + return "/" + getFullPath() + " " + path.get( path.size() - 1 ).getUsage( this ); + } + + public List getPath() + { + return Collections.unmodifiableList( path ); + } + + public String getRootCommand() + { + return path.get( 0 ).getName(); + } + + public MinecraftServer getServer() + { + return server; + } + + public ICommandSender getSender() + { + return sender; + } +} diff --git a/src/main/java/dan200/computercraft/shared/command/framework/CommandDelegate.java b/src/main/java/dan200/computercraft/shared/command/framework/CommandDelegate.java new file mode 100644 index 000000000..02c54fb84 --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/framework/CommandDelegate.java @@ -0,0 +1,91 @@ +package dan200.computercraft.shared.command.framework; + +import dan200.computercraft.ComputerCraft; +import net.minecraft.command.CommandException; +import net.minecraft.command.ICommand; +import net.minecraft.command.ICommandSender; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.BlockPos; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * {@link net.minecraft.command.ICommand} which delegates to a {@link ISubCommand}. + */ +public class CommandDelegate implements ICommand +{ + private final ISubCommand command; + + public CommandDelegate( ISubCommand command ) + { + this.command = command; + } + + @Nonnull + @Override + public String getName() + { + return command.getName(); + } + + @Nonnull + @Override + public String getUsage( @Nonnull ICommandSender sender ) + { + return "/" + command.getName() + " " + command.getUsage( new CommandContext( sender.getServer(), sender, command ) ); + } + + @Nonnull + @Override + public List getAliases() + { + return Collections.emptyList(); + } + + @Override + public void execute( @Nonnull MinecraftServer server, @Nonnull ICommandSender sender, @Nonnull String[] args ) throws CommandException + { + try + { + command.execute( new CommandContext( server, sender, command ), Arrays.asList( args ) ); + } + catch( CommandException e ) + { + throw e; + } + catch( Throwable e ) + { + ComputerCraft.log.error( "Unhandled exception in command", e ); + throw new CommandException( "Unhandled exception: " + e.toString() ); + } + } + + @Nonnull + @Override + public List getTabCompletions( @Nonnull MinecraftServer server, @Nonnull ICommandSender sender, @Nonnull String[] args, @Nullable BlockPos pos ) + { + return command.getCompletion( new CommandContext( server, sender, command ), Arrays.asList( args ) ); + } + + @Override + public boolean checkPermission( @Nonnull MinecraftServer server, @Nonnull ICommandSender sender ) + { + return command.checkPermission( new CommandContext( server, sender, command ) ); + } + + @Override + public boolean isUsernameIndex( @Nonnull String[] args, int index ) + { + return false; + } + + @Override + public int compareTo( @Nonnull ICommand o ) + { + return getName().compareTo( o.getName() ); + } +} diff --git a/src/main/java/dan200/computercraft/shared/command/framework/CommandRoot.java b/src/main/java/dan200/computercraft/shared/command/framework/CommandRoot.java new file mode 100644 index 000000000..20deee7c7 --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/framework/CommandRoot.java @@ -0,0 +1,149 @@ +package dan200.computercraft.shared.command.framework; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import net.minecraft.command.CommandBase; +import net.minecraft.command.CommandException; + +import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * A command which delegates to a series of sub commands + */ +public class CommandRoot implements ISubCommand +{ + private final String name; + private final String synopsis; + private final String description; + private final Map subCommands = Maps.newHashMap(); + + public CommandRoot( String name, String synopsis, String description ) + { + this.name = name; + this.synopsis = synopsis; + this.description = description; + + register( new SubCommandHelp( this ) ); + } + + public void register( ISubCommand command ) + { + subCommands.put( command.getName(), command ); + } + + @Nonnull + @Override + public String getName() + { + return name; + } + + @Nonnull + @Override + public String getUsage( CommandContext context ) + { + StringBuilder out = new StringBuilder( "<" ); + boolean first = true; + for( ISubCommand command : subCommands.values() ) + { + if( command.checkPermission( context ) ) + { + if( first ) + { + first = false; + } + else + { + out.append( "|" ); + } + + out.append( command.getName() ); + } + } + + return out.append( ">" ).toString(); + } + + @Nonnull + @Override + public String getSynopsis() + { + return synopsis; + } + + @Nonnull + @Override + public String getDescription() + { + return description; + } + + @Override + public boolean checkPermission( @Nonnull CommandContext context ) + { + for( ISubCommand command : subCommands.values() ) + { + if( command.checkPermission( context ) ) return true; + } + return false; + } + + public Map getSubCommands() + { + return Collections.unmodifiableMap( subCommands ); + } + + @Override + public void execute( @Nonnull CommandContext context, @Nonnull List arguments ) throws CommandException + { + if( arguments.size() == 0 ) + { + context.getSender().sendMessage( ChatHelpers.getHelp( context, this, context.getFullPath() ) ); + } + else + { + ISubCommand command = subCommands.get( arguments.get( 0 ) ); + if( command == null || !command.checkPermission( context ) ) + { + throw new CommandException( getName() + " " + getUsage( context ) ); + } + + command.execute( context.enter( command ), arguments.subList( 1, arguments.size() ) ); + } + } + + @Nonnull + @Override + public List getCompletion( @Nonnull CommandContext context, @Nonnull List arguments ) + { + if( arguments.size() == 0 ) + { + return Lists.newArrayList( subCommands.keySet() ); + } + else if( arguments.size() == 1 ) + { + List list = Lists.newArrayList(); + String match = arguments.get( 0 ); + + for( ISubCommand command : subCommands.values() ) + { + if( CommandBase.doesStringStartWith( match, command.getName() ) && command.checkPermission( context ) ) + { + list.add( command.getName() ); + } + } + + return list; + } + else + { + ISubCommand command = subCommands.get( arguments.get( 0 ) ); + if( command == null || !command.checkPermission( context ) ) return Collections.emptyList(); + + return command.getCompletion( context, arguments.subList( 1, arguments.size() ) ); + } + } +} diff --git a/src/main/java/dan200/computercraft/shared/command/framework/ISubCommand.java b/src/main/java/dan200/computercraft/shared/command/framework/ISubCommand.java new file mode 100644 index 000000000..77d0773e4 --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/framework/ISubCommand.java @@ -0,0 +1,80 @@ +package dan200.computercraft.shared.command.framework; + +import net.minecraft.command.CommandException; +import net.minecraft.command.ICommand; +import net.minecraft.command.ICommandSender; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.BlockPos; + +import javax.annotation.Nonnull; +import java.util.List; + +/** + * A slightly different implementation of {@link ICommand} which is delegated to. + */ +public interface ISubCommand +{ + /** + * Get the name of this command + * + * @return The name of this command + * @see ICommand#getName() + */ + @Nonnull + String getName(); + + /** + * Get the usage of this command + * + * @param context The context this command is executed in + * @return The usage of this command + * @see ICommand#getUsage(ICommandSender) + */ + @Nonnull + String getUsage( CommandContext context ); + + /** + * Get a short description of this command, including its usage. + * + * @return The command's synopsis + */ + @Nonnull + String getSynopsis(); + + /** + * Get the lengthy description of this command. This synopsis is prepended to this. + * + * @return The command's description + */ + @Nonnull + String getDescription(); + + /** + * Determine whether a given command sender has permission to execute this command. + * + * @param context The current command context. + * @return Whether this command can be executed. + * @see ICommand#checkPermission(MinecraftServer, ICommandSender) + */ + boolean checkPermission( @Nonnull CommandContext context ); + + /** + * Execute this command + * + * @param context The current command context. + * @param arguments The arguments passed @throws CommandException When an error occurs + * @see ICommand#execute(MinecraftServer, ICommandSender, String[]) + */ + void execute( @Nonnull CommandContext context, @Nonnull List arguments ) throws CommandException; + + /** + * Get a list of possible completions + * + * @param context The current command context. + * @param arguments The arguments passed. You should complete the last one. + * @return List of possible completions + * @see ICommand#getTabCompletions(MinecraftServer, ICommandSender, String[], BlockPos) + */ + @Nonnull + List getCompletion( @Nonnull CommandContext context, @Nonnull List arguments ); +} diff --git a/src/main/java/dan200/computercraft/shared/command/framework/SubCommandBase.java b/src/main/java/dan200/computercraft/shared/command/framework/SubCommandBase.java new file mode 100644 index 000000000..ae79230c3 --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/framework/SubCommandBase.java @@ -0,0 +1,69 @@ +package dan200.computercraft.shared.command.framework; + +import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.List; + +public abstract class SubCommandBase implements ISubCommand +{ + private final String name; + private final String usage; + private final String synopsis; + private final String description; + private final UserLevel level; + + public SubCommandBase( String name, String usage, String synopsis, UserLevel level, String description ) + { + this.name = name; + this.usage = usage; + this.synopsis = synopsis; + this.description = description; + this.level = level; + } + + public SubCommandBase( String name, String synopsis, UserLevel level, String description ) + { + this( name, "", synopsis, level, description ); + } + + @Nonnull + @Override + public String getName() + { + return name; + } + + @Nonnull + @Override + public String getUsage( CommandContext context ) + { + return usage; + } + + @Nonnull + @Override + public String getSynopsis() + { + return synopsis; + } + + @Nonnull + @Override + public String getDescription() + { + return description; + } + + @Override + public boolean checkPermission( @Nonnull CommandContext context ) + { + return level.canExecute( context ); + } + + @Nonnull + @Override + public List getCompletion( @Nonnull CommandContext context, @Nonnull List arguments ) + { + return Collections.emptyList(); + } +} diff --git a/src/main/java/dan200/computercraft/shared/command/framework/SubCommandHelp.java b/src/main/java/dan200/computercraft/shared/command/framework/SubCommandHelp.java new file mode 100644 index 000000000..b094b1293 --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/framework/SubCommandHelp.java @@ -0,0 +1,127 @@ +package dan200.computercraft.shared.command.framework; + +import com.google.common.collect.Lists; +import joptsimple.internal.Strings; +import net.minecraft.command.CommandBase; +import net.minecraft.command.CommandException; + +import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.List; + +public class SubCommandHelp implements ISubCommand +{ + private final CommandRoot branchCommand; + + public SubCommandHelp( CommandRoot branchCommand ) + { + this.branchCommand = branchCommand; + } + + @Nonnull + @Override + public String getName() + { + return "help"; + } + + @Nonnull + @Override + public String getUsage( CommandContext context ) + { + return "[command]"; + } + + @Nonnull + @Override + public String getSynopsis() + { + return "Provide help for a specific command"; + } + + @Nonnull + @Override + public String getDescription() + { + return ""; + } + + @Override + public boolean checkPermission( @Nonnull CommandContext context ) + { + return true; + } + + @Override + public void execute( @Nonnull CommandContext context, @Nonnull List arguments ) throws CommandException + { + ISubCommand command = branchCommand; + + for( int i = 0; i < arguments.size(); i++ ) + { + String commandName = arguments.get( i ); + if( command instanceof CommandRoot ) + { + command = ((CommandRoot) command).getSubCommands().get( commandName ); + } + else + { + throw new CommandException( Strings.join( arguments.subList( 0, i ), " " ) + " has no sub-commands" ); + } + + if( command == null ) + { + throw new CommandException( "No such command " + Strings.join( arguments.subList( 0, i + 1 ), " " ) ); + } + } + + StringBuilder prefix = new StringBuilder( context.parent().getFullPath() ); + for( String argument : arguments ) + { + prefix.append( ' ' ).append( argument ); + } + context.getSender().sendMessage( ChatHelpers.getHelp( context, command, prefix.toString() ) ); + } + + @Nonnull + @Override + public List getCompletion( @Nonnull CommandContext context, @Nonnull List arguments ) + { + CommandRoot command = branchCommand; + + for( int i = 0; i < arguments.size() - 1; i++ ) + { + String commandName = arguments.get( i ); + ISubCommand subCommand = command.getSubCommands().get( commandName ); + + if( subCommand instanceof CommandRoot ) + { + command = (CommandRoot) subCommand; + } + else + { + return Collections.emptyList(); + } + } + + if( arguments.size() == 0 ) + { + return Lists.newArrayList( command.getSubCommands().keySet() ); + } + else + { + List list = Lists.newArrayList(); + String match = arguments.get( arguments.size() - 1 ); + + for( String entry : command.getSubCommands().keySet() ) + { + if( CommandBase.doesStringStartWith( match, entry ) ) + { + list.add( entry ); + } + } + + return list; + } + } +} diff --git a/src/main/java/dan200/computercraft/shared/command/framework/TextTable.java b/src/main/java/dan200/computercraft/shared/command/framework/TextTable.java new file mode 100644 index 000000000..005b50388 --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/framework/TextTable.java @@ -0,0 +1,250 @@ +package dan200.computercraft.shared.command.framework; + +import com.google.common.collect.Lists; +import net.minecraft.command.ICommandSender; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.TextComponentString; +import net.minecraft.util.text.TextFormatting; +import net.minecraftforge.common.util.FakePlayer; +import org.apache.commons.lang3.StringUtils; + +import javax.annotation.Nonnull; +import java.util.List; + +import static dan200.computercraft.shared.command.framework.ChatHelpers.coloured; +import static dan200.computercraft.shared.command.framework.ChatHelpers.text; + +public class TextTable +{ + private static final String CHARACTERS = "\u00c0\u00c1\u00c2\u00c8\u00ca\u00cb\u00cd\u00d3\u00d4\u00d5\u00da\u00df\u00e3\u00f5\u011f\u0130\u0131\u0152\u0153\u015e\u015f\u0174\u0175\u017e\u0207\u0000\u0000\u0000\u0000\u0000\u0000\u0000 !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\u0000\u00c7\u00fc\u00e9\u00e2\u00e4\u00e0\u00e5\u00e7\u00ea\u00eb\u00e8\u00ef\u00ee\u00ec\u00c4\u00c5\u00c9\u00e6\u00c6\u00f4\u00f6\u00f2\u00fb\u00f9\u00ff\u00d6\u00dc\u00f8\u00a3\u00d8\u00d7\u0192\u00e1\u00ed\u00f3\u00fa\u00f1\u00d1\u00aa\u00ba\u00bf\u00ae\u00ac\u00bd\u00bc\u00a1\u00ab\u00bb\u2591\u2592\u2593\u2502\u2524\u2561\u2562\u2556\u2555\u2563\u2551\u2557\u255d\u255c\u255b\u2510\u2514\u2534\u252c\u251c\u2500\u253c\u255e\u255f\u255a\u2554\u2569\u2566\u2560\u2550\u256c\u2567\u2568\u2564\u2565\u2559\u2558\u2552\u2553\u256b\u256a\u2518\u250c\u2588\u2584\u258c\u2590\u2580\u03b1\u03b2\u0393\u03c0\u03a3\u03c3\u03bc\u03c4\u03a6\u0398\u03a9\u03b4\u221e\u2205\u2208\u2229\u2261\u00b1\u2265\u2264\u2320\u2321\u00f7\u2248\u00b0\u2219\u00b7\u221a\u207f\u00b2\u25a0\u0000"; + private static final int[] CHAR_WIDTHS = new int[] { + 6, 6, 6, 6, 6, 6, 4, 6, 6, 6, 6, 6, 6, 6, 6, 4, + 4, 6, 7, 6, 6, 6, 6, 6, 6, 1, 1, 1, 1, 1, 1, 1, + 1, 2, 5, 6, 6, 6, 6, 3, 5, 5, 5, 6, 2, 6, 2, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 2, 2, 5, 6, 5, 6, + 7, 6, 6, 6, 6, 6, 6, 6, 6, 4, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 4, 6, 4, 6, 6, + 3, 6, 6, 6, 6, 6, 5, 6, 6, 2, 6, 5, 3, 6, 6, 6, + 6, 6, 6, 6, 4, 6, 6, 6, 6, 6, 6, 5, 2, 5, 7, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 4, 6, 3, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 4, 6, + 6, 3, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 6, 2, 6, 6, + 8, 9, 9, 6, 6, 6, 8, 8, 6, 8, 8, 8, 8, 8, 6, 6, + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, 9, 6, 9, 9, 9, 5, 9, 9, + 8, 7, 7, 8, 7, 8, 8, 8, 7, 8, 8, 7, 9, 9, 6, 7, + 7, 7, 7, 7, 9, 6, 7, 8, 7, 6, 6, 9, 7, 6, 7, 1 + }; + + private static final ITextComponent SEPARATOR = coloured( " | ", TextFormatting.GRAY ); + private static final ITextComponent LINE = text( "\n" ); + + private static int getWidth( char character, ICommandSender sender ) + { + if( sender instanceof EntityPlayerMP && !(sender instanceof FakePlayer) ) + { + // Use font widths here. + if( character == 167 ) + { + return -1; + } + else if( character == 32 ) + { + return 4; + } + else if( CHARACTERS.indexOf( character ) != -1 ) + { + return CHAR_WIDTHS[ character ]; + } + else + { + // Eh, close enough. + return 6; + } + } + else + { + return 1; + } + } + + private static int getWidth( ITextComponent text, ICommandSender sender ) + { + int sum = 0; + String chars = text.getUnformattedText(); + for( int i = 0; i < chars.length(); i++ ) + { + sum += getWidth( chars.charAt( i ), sender ); + } + + return sum; + } + + private static boolean isPlayer( ICommandSender sender ) + { + return sender instanceof EntityPlayerMP && !(sender instanceof FakePlayer); + } + + private static int getMaxWidth( ICommandSender sender ) + { + return isPlayer( sender ) ? 320 : 80; + } + + private int columns = -1; + private final ITextComponent[] header; + private final List rows = Lists.newArrayList(); + + public TextTable( @Nonnull ITextComponent... header ) + { + this.header = header; + this.columns = header.length; + } + + public TextTable() + { + header = null; + } + + public TextTable( @Nonnull String... header ) + { + this.header = new ITextComponent[ header.length ]; + for( int i = 0; i < header.length; i++ ) + { + this.header[ i ] = ChatHelpers.header( header[ i ] ); + } + this.columns = header.length; + } + + public void addRow( @Nonnull ITextComponent... row ) + { + if( columns == -1 ) + { + columns = row.length; + } + else if( row.length != columns ) + { + throw new IllegalArgumentException( "Row is the incorrect length" ); + } + + rows.add( row ); + } + + public void displayTo( ICommandSender sender ) + { + if( columns <= 0 ) return; + + final int maxWidth = getMaxWidth( sender ); + + int[] minWidths = new int[ columns ]; + int[] maxWidths = new int[ columns ]; + int[] rowWidths = new int[ columns ]; + + if( header != null ) + { + for( int i = 0; i < columns; i++ ) + { + maxWidths[ i ] = minWidths[ i ] = getWidth( header[ i ], sender ); + } + } + + for( ITextComponent[] row : rows ) + { + for( int i = 0; i < row.length; i++ ) + { + int width = getWidth( row[ i ], sender ); + rowWidths[ i ] += width; + if( width > maxWidths[ i ] ) + { + maxWidths[ i ] = width; + } + } + } + + // Calculate the average width + for( int i = 0; i < columns; i++ ) + { + rowWidths[ i ] = Math.max( rowWidths[ i ], rows.size() ); + } + + int totalWidth = (columns - 1) * getWidth( SEPARATOR, sender ); + for( int x : maxWidths ) totalWidth += x; + + // TODO: Limit the widths of some entries if totalWidth > maxWidth + + ITextComponent out = new TextComponentString( "" ); + + if( header != null ) + { + for( int i = 0; i < columns; i++ ) + { + if( i != 0 ) out.appendSibling( SEPARATOR ); + appendFixed( out, sender, header[ i ], maxWidths[ i ] ); + } + out.appendSibling( LINE ); + + // Round the width up rather than down + int rowCharWidth = getWidth( '=', sender ); + int rowWidth = totalWidth / rowCharWidth + (totalWidth % rowCharWidth == 0 ? 0 : 1); + out.appendSibling( coloured( StringUtils.repeat( '=', rowWidth ), TextFormatting.GRAY ) ); + out.appendSibling( LINE ); + } + + for( int i = 0; i < rows.size(); i++ ) + { + ITextComponent[] row = rows.get( i ); + if( i != 0 ) out.appendSibling( LINE ); + for( int j = 0; j < columns; j++ ) + { + if( j != 0 ) out.appendSibling( SEPARATOR ); + appendFixed( out, sender, row[ j ], maxWidths[ j ] ); + } + } + + sender.sendMessage( out ); + } + + private static void appendFixed( ITextComponent out, ICommandSender sender, ITextComponent entry, int maxWidth ) + { + int length = getWidth( entry, sender ); + int delta = length - maxWidth; + if( delta < 0 ) + { + // Convert to overflow; + delta = -delta; + + // We have to remove some padding as there is a padding added between formatted and unformatted text + if( !entry.getStyle().isEmpty() && isPlayer( sender ) ) delta -= 1; + + out.appendSibling( entry ); + + int spaceWidth = getWidth( ' ', sender ); + + int spaces = delta / spaceWidth; + int missing = delta % spaceWidth; + spaces -= missing; + + ITextComponent component = new TextComponentString( StringUtils.repeat( ' ', spaces < 0 ? 0 : spaces ) ); + if( missing > 0 ) + { + ITextComponent bold = new TextComponentString( StringUtils.repeat( ' ', missing ) ); + bold.getStyle().setBold( true ); + component.appendSibling( bold ); + } + + out.appendSibling( component ); + } + else if( delta > 0 ) + { + out.appendSibling( entry ); + } + else + { + out.appendSibling( entry ); + + // We have to add some padding as we expect a padding between formatted and unformatted text + // and there won't be. + if( entry.getStyle().isEmpty() && isPlayer( sender ) ) out.appendText( " " ); + } + } +} diff --git a/src/main/java/dan200/computercraft/shared/command/framework/UserLevel.java b/src/main/java/dan200/computercraft/shared/command/framework/UserLevel.java new file mode 100644 index 000000000..4c866d025 --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/command/framework/UserLevel.java @@ -0,0 +1,63 @@ +package dan200.computercraft.shared.command.framework; + +import net.minecraft.command.ICommandSender; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.server.MinecraftServer; + +/** + * The level a user must be at in order to execute a command. + */ +public enum UserLevel +{ + /** + * Only can be used by the owner of the server: namely the server console or the player in SSP. + */ + OWNER, + + /** + * Can only be used by ops. + */ + OP, + + /** + * Can be used by any op, or the player in SSP. + */ + OWNER_OP, + + /** + * Can be used by anyone. + */ + ANYONE; + + public int toLevel() + { + switch( this ) + { + case OWNER: + return 4; + case OP: + case OWNER_OP: + return 2; + case ANYONE: + default: + return 0; + } + } + + public boolean canExecute( CommandContext context ) + { + if( this == ANYONE ) return true; + + // We *always* allow level 0 stuff, even if the + MinecraftServer server = context.getServer(); + ICommandSender sender = context.getSender(); + + if( server.isSinglePlayer() && sender instanceof EntityPlayerMP && + ((EntityPlayerMP) sender).getGameProfile().getName().equalsIgnoreCase( server.getServerOwner() ) ) + { + if( this == OWNER || this == OWNER_OP ) return true; + } + + return sender.canUseCommand( toLevel(), context.getRootCommand() ); + } +}