package tc.oc.pgm.development; import java.io.File; import java.io.IOException; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.inject.Inject; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; import com.sk89q.bukkit.util.BukkitWrappedCommandSender; import com.sk89q.minecraft.util.commands.Command; import com.sk89q.minecraft.util.commands.CommandContext; import com.sk89q.minecraft.util.commands.CommandException; import com.sk89q.minecraft.util.commands.CommandPermissions; import net.md_5.bungee.api.ChatColor; import net.md_5.bungee.api.chat.TranslatableComponent; import org.apache.commons.io.FileUtils; import org.bukkit.World; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.util.Vector; import org.jdom2.Element; import java.time.Duration; import tc.oc.api.docs.PlayerId; import tc.oc.minecraft.scheduler.SyncExecutor; import tc.oc.api.util.Permissions; import tc.oc.commons.bukkit.chat.Audiences; import tc.oc.commons.bukkit.commands.CommandUtils; import tc.oc.commons.bukkit.commands.PrettyPaginatedResult; import tc.oc.commons.bukkit.util.BukkitUtils; import tc.oc.commons.core.chat.Audience; import tc.oc.commons.core.chat.Component; import tc.oc.commons.core.commands.CommandFutureCallback; import tc.oc.commons.core.commands.Commands; import tc.oc.commons.core.inspect.Inspection; import tc.oc.commons.core.inspect.MultiLineTextInspector; import tc.oc.pgm.PGMTranslations; import tc.oc.pgm.features.Feature; import tc.oc.pgm.features.FeatureDefinition; import tc.oc.pgm.features.FeatureProxy; import tc.oc.pgm.features.SluggedFeature; import tc.oc.pgm.filters.Filter; import tc.oc.pgm.map.MapConfiguration; import tc.oc.pgm.map.MapDefinition; import tc.oc.pgm.map.MapLibrary; import tc.oc.pgm.map.MapLogRecord; import tc.oc.pgm.map.MapNotFoundException; import tc.oc.pgm.map.PGMMap; import tc.oc.pgm.match.Match; import tc.oc.pgm.match.MatchManager; import tc.oc.pgm.match.MatchPlayer; import tc.oc.pgm.match.MatchScope; import tc.oc.pgm.match.MatchState; import tc.oc.pgm.physics.AccelerationPlayerFacet; import tc.oc.pgm.physics.DebugVelocityPlayerFacet; import tc.oc.pgm.physics.PlayerForce; import tc.oc.pgm.xml.Node; public class MapDevelopmentCommands implements Commands { private final MapErrorTracker mapErrorTracker; private final Map<String, Boolean> mapEnvironment; private final MapLibrary mapLibrary; private final MatchManager matchManager; private final Audiences audiences; private final SyncExecutor syncExecutor; @Inject MapDevelopmentCommands(MapErrorTracker mapErrorTracker, MapConfiguration mapConfiguration, MapLibrary mapLibrary, MatchManager matchManager, Audiences audiences, SyncExecutor syncExecutor) { this.mapErrorTracker = mapErrorTracker; this.mapEnvironment = mapConfiguration.environment(); this.mapLibrary = mapLibrary; this.matchManager = matchManager; this.audiences = audiences; this.syncExecutor = syncExecutor; } @Command( aliases = {"clearerrors", "clearxmlerrors"}, desc = "Clears XML errors" ) @CommandPermissions(Permissions.MAPERRORS) public void clearErrorsCommand(CommandContext args, CommandSender sender) throws CommandException { mapErrorTracker.clearAllErrors(); sender.sendMessage(ChatColor.GREEN + PGMTranslations.get().t("command.development.clearErrors.success", sender)); } @Command( aliases = {"errors", "xmlerrors"}, usage = "[-p page] [map name]", desc = "Reads back XML errors", min = 0, max = -1, flags = "p:" ) @CommandPermissions(Permissions.MAPERRORS) public List<String> errorsCommand(CommandContext args, final CommandSender sender) throws CommandException { final String mapName = args.argsLength() > 0 ? args.getJoinedStrings(0) : ""; if(args.getSuggestionContext() != null) { return tc.oc.pgm.commands.CommandUtils.completeMapName(mapName); } Multimap<MapDefinition, MapLogRecord> errors = mapErrorTracker.getErrors(); PGMMap filterMap = null; if(!mapName.isEmpty()) { filterMap = tc.oc.pgm.commands.CommandUtils.getMap(mapName, sender); Multimap<MapDefinition, MapLogRecord> filtered = ArrayListMultimap.create(); filtered.putAll(filterMap, errors.get(filterMap)); errors = filtered; } new PrettyPaginatedResult<Map.Entry<MapDefinition, MapLogRecord>>(filterMap == null ? "Map Errors (" + errors.keySet().size() + " maps)" : filterMap.getName() + " Errors") { @Override public String format(Map.Entry<MapDefinition, MapLogRecord> entry, int index) { return entry.getValue().getLegacyFormattedMessage(); } @Override public String formatEmpty() { return ChatColor.GREEN + PGMTranslations.get().t("command.development.listErrors.noErrors", sender); } }.display(new BukkitWrappedCommandSender(sender), errors.entries(), args.getFlagInteger('p', 1)); return null; } @Command( aliases = {"loadnewmaps", "findnewmaps", "newmaps"}, desc = "Scan for new maps and load them", min = 0, max = 0 ) @CommandPermissions("pgm.loadnewmaps") public void loadNewMaps(CommandContext args, CommandSender sender) throws CommandException { final Audience audience = audiences.get(sender); audience.sendMessage(new Component(new TranslatableComponent("command.loadNewMaps.loading"), ChatColor.WHITE)); // Clear errors for maps that failed to load, because we want to see those errors again mapErrorTracker.clearErrorsExcept(mapLibrary.getMaps()); try { final Set<PGMMap> newMaps = matchManager.loadMapsAndRotations(); if(newMaps.isEmpty()) { audience.sendMessage(new Component(new TranslatableComponent("command.loadNewMaps.noNewMaps"), ChatColor.WHITE)); } else if(newMaps.size() == 1) { audience.sendMessage(new Component(new TranslatableComponent("command.loadNewMaps.foundSingleMap", new Component(newMaps.iterator().next().getInfo().name, ChatColor.YELLOW)), ChatColor.WHITE)); } else { audience.sendMessage(new Component(new TranslatableComponent("command.loadNewMaps.foundMultipleMaps", new Component(Integer.toString(newMaps.size()), ChatColor.AQUA)), ChatColor.WHITE)); } } catch(MapNotFoundException e) { audience.sendWarning(new TranslatableComponent("command.loadNewMaps.noMaps"), false); } } @Command( aliases = {"matchfeatures", "features"}, desc = "Lists all features by ID and type", min = 0, max = 1 ) @CommandPermissions(Permissions.MAPDEV) public void featuresCommand(CommandContext args, CommandSender sender) throws CommandException { final Match match = tc.oc.pgm.commands.CommandUtils.getMatch(sender); new PrettyPaginatedResult<Feature>("Match Features") { @Override public String format(Feature feature, int i) { String text = (i + 1) + ". " + ChatColor.RED + feature.getClass().getSimpleName(); if(feature instanceof SluggedFeature) { text += ChatColor.GRAY + " - " +ChatColor.GOLD + ((SluggedFeature) feature).slug(); } return text; } }.display(new BukkitWrappedCommandSender(sender), match.features().all().collect(Collectors.toList()), args.getInteger(0, 1)); } @Command( aliases = {"mapfeatures", "mapf"}, desc = "Lists all map features by ID and type", usage = "[-a(nonymous)] [-v(erbose)] [-l(ocations)] [-t type] [-i id]", flags = "avlt:i:", min = 0, max = 0 ) @CommandPermissions(Permissions.MAPDEV) public void mapFeaturesCommand(CommandContext args, CommandSender sender) throws CommandException { final PGMMap map = tc.oc.pgm.commands.CommandUtils.getMatch(sender).getMap(); final boolean verbose = args.hasFlag('v'); final boolean locate = args.hasFlag('l'); final boolean anonymous = args.hasFlag('a'); final Optional<String> typeFilter = CommandUtils.flag(args, 't').map(String::toLowerCase); final Optional<String> idFilter = CommandUtils.flag(args, 'i').map(String::toLowerCase); Stream<? extends FeatureDefinition> features = map.getContext().features().all(); if(typeFilter.isPresent()) { features = features.filter(f -> f.inspectType().toLowerCase().contains(typeFilter.get())); } if(idFilter.isPresent()) { features = features.filter(f -> f instanceof FeatureProxy && ((FeatureProxy) f).getId().toLowerCase().contains(idFilter.get())); } else if(!anonymous) { features = features.filter(f -> f instanceof FeatureProxy); } features.forEach(feature -> { final Component c = new Component(feature.inspectType(), ChatColor.BLUE); feature.inspectIdentity().ifPresent(id -> c.extra(" ").extra(new Component(id, ChatColor.YELLOW))); if(locate) { final Element element = map.getContext().features().definitionNode(feature); if(element != null) { c.extra(" ").extra(new Component(new Node(element).describeWithLocation(), ChatColor.DARK_AQUA)); } } sender.sendMessage(c); if(verbose) { feature.inspect(new MultiLineTextInspector(), Inspection.defaults()) .forEach(line -> sender.sendMessage(new Component(" " + BukkitUtils.escapeColors(line), ChatColor.GOLD))); } }); } @Command( aliases = {"feature", "fl"}, desc = "Prints information regarding a specific feature", min = 1, max = -1 ) @CommandPermissions(Permissions.MAPDEV) public void featureCommand(CommandContext args, CommandSender sender) throws CommandException { final String slug = args.getJoinedStrings(0); final Optional<Feature<?>> feature = matchManager.getCurrentMatch(sender).features().bySlug(slug); if(feature.isPresent()) { sender.sendMessage(ChatColor.GOLD + slug + ChatColor.GRAY + " corresponds to: " + ChatColor.WHITE + feature.get().toString()); } else { sender.sendMessage(ChatColor.RED + "No feature by the name of " + ChatColor.GOLD + slug + ChatColor.RED + " was found."); } } @Command( aliases = {"velocity", "vel"}, desc = "Apply a velocity to a player", min = 3, max = 4 ) @CommandPermissions(Permissions.MAPDEV) public void velocity(CommandContext args, CommandSender sender) throws CommandException { Player target = CommandUtils.getPlayerOrSelf(args, sender, 3); Vector velocity = new Vector(args.getDouble(0), args.getDouble(1), args.getDouble(2)); sender.sendMessage(String.format("Applying velocity (%.2f, %.2f, %.2f) to " + target.getName(sender), velocity.getX(), velocity.getY(), velocity.getZ())); target.setVelocity(velocity); } @Command( aliases = {"impulse", "imp"}, desc = "Apply an impulse to a player", min = 3, max = 4 ) @CommandPermissions(Permissions.MAPDEV) public void impulse(CommandContext args, CommandSender sender) throws CommandException { Player target = CommandUtils.getPlayerOrSelf(args, sender, 3); Vector impulse = new Vector(args.getDouble(0), args.getDouble(1), args.getDouble(2)); sender.sendMessage(String.format("Applying impulse (%.2f, %.2f, %.2f) to " + target.getName(sender), impulse.getX(), impulse.getY(), impulse.getZ())); target.applyImpulse(impulse, true); } @Command( aliases = {"accelerate", "acc"}, desc = "Apply a continuous force to a player for a period of time", min = 4, max = 5 ) @CommandPermissions(Permissions.MAPDEV) public void accelerate(CommandContext args, CommandSender sender) throws CommandException { final MatchPlayer target = tc.oc.pgm.commands.CommandUtils.getMatchPlayerOrSelf(args, sender, 4); final Duration duration = CommandUtils.getDuration(args, 0); final Vector accel = new Vector(args.getDouble(1), args.getDouble(2), args.getDouble(3)).multiply(1d / 20d); // per-tick sender.sendMessage(String.format("Applying force (%.2f, %.2f, %.2f) to %s for %s", accel.getX(), accel.getY(), accel.getZ(), target.getName(sender), duration)); final PlayerForce force = target.facet(AccelerationPlayerFacet.class).addForce(accel); final Match match = target.getMatch(); final PlayerId playerId = target.getPlayerId(); match.getScheduler(MatchScope.LOADED).createDelayedTask(duration, () -> { final MatchPlayer target0 = match.getPlayer(playerId); if(target0 != null) { target0.facet(AccelerationPlayerFacet.class).removeForce(force); } }); } @Command( aliases = {"filter", "fil"}, desc = "Query a filter by ID with yourself or the given player", usage = "filter [player]", min = 1, max = 2 ) @CommandPermissions(Permissions.MAPDEV) public void filter(CommandContext args, CommandSender sender) throws CommandException { MatchPlayer target = tc.oc.pgm.commands.CommandUtils.getMatchPlayerOrSelf(args, sender, 1); String id = args.getString(0); Filter filter = tc.oc.pgm.commands.CommandUtils.getFeatureDefinition(id, sender, Filter.class); Filter.QueryResponse response = filter.query(target); String out = ChatColor.BLUE + id + ChatColor.DARK_GRAY + "(" + ChatColor.GRAY + target.getName(sender) + ChatColor.DARK_GRAY + ") -> "; switch(response) { case DENY: out += ChatColor.DARK_RED; break; case ABSTAIN: out += ChatColor.YELLOW; break; case ALLOW: out += ChatColor.GREEN; break; } sender.sendMessage(out + response.name()); } @Command( aliases = {"updatemap", "savemap"}, desc = "Updates the original map file to changes in game", flags = "f" ) @CommandPermissions("pgm.updatemap") public void updateMap(CommandContext args, final CommandSender sender) throws CommandException { final Match match = matchManager.getCurrentMatch(sender); final PGMMap map = match.getMap(); final Logger logger = map.getLogger(); if(match.matchState() != MatchState.Idle && !args.hasFlag('f')) { sender.sendMessage(ChatColor.RED + PGMTranslations.get().t("command.map.update.running", sender)); } else { World world = match.getWorld(); logger.info("Saving world"); world.save(); final Path worldFolder = world.getWorldFolder().toPath(); try { // Prune void regions from the world Path regionFolder = worldFolder.resolve("region"); File[] regionFiles = regionFolder.toFile().listFiles(); if(regionFiles == null) { logger.info("No region folder"); } else { logger.info("Pruning empty regions"); for(File file : regionFiles) { Matcher matcher = Pattern.compile("r\\.(-?\\d+).(-?\\d+).mca").matcher(file.getName()); if(!matcher.matches()) continue; int regionX = Integer.parseInt(matcher.group(1)); int regionZ = Integer.parseInt(matcher.group(2)); int minX = regionX << 5; int minZ = regionZ << 5; int maxX = minX + 32; int maxZ = minZ + 32; boolean empty = true; for(int x = minX; x < maxX; x++) { for(int z = minZ; z < maxZ; z++) { if(!world.getChunkAt(x, z).isEmpty()) empty = false; } } if(empty) { logger.info(" empty: " + file.getName()); if(!file.delete()) { throw new CommandException(PGMTranslations.get().t("command.map.update.deleteFailed", sender, file.getName())); } } else { logger.info(" non-empty: " + file.getName()); } } } final Path sourceWorldFolder = map.getFolder().getAbsolutePath(); Path sourceRegionFolder = sourceWorldFolder.resolve("region"); Path sourceRegionBackup = sourceWorldFolder.resolve("region_backup"); if(Files.exists(sourceRegionFolder)) { logger.info("Copying source /region to /region_backup"); Files.copy(sourceRegionFolder, sourceRegionBackup, StandardCopyOption.REPLACE_EXISTING); } logger.info("Searching for changed files"); Files.walkFileTree(worldFolder, new SimpleFileVisitor<Path>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { Path relativePath = worldFolder.relativize(file); File sourceFile = sourceWorldFolder.resolve(relativePath).toFile(); // Always copy region files (mca) if(!sourceFile.exists() && !file.getFileName().toString().endsWith(".mca")) { logger.info(" skipping: " + relativePath); } else if(FileUtils.contentEquals(file.toFile(), sourceFile)) { logger.info(" unchanged: " + relativePath); } else { logger.info(" changed: " + relativePath); Files.copy(file, sourceFile.toPath(), StandardCopyOption.REPLACE_EXISTING); } return FileVisitResult.CONTINUE; } }); if(Files.exists(sourceRegionBackup)) { logger.info("Deleting region backup"); Files.delete(sourceRegionBackup); } } catch(IOException e) { throw new CommandException(e.toString()); } sender.sendMessage(ChatColor.GREEN + PGMTranslations.get().t("command.map.update.success", sender)); } } @Command( aliases = {"pushmaps"}, desc = "Synchronizes ALL loaded maps with the database", min = 0, max = 0 ) @CommandPermissions(Permissions.DEVELOPER) public void pushMaps(CommandContext args, final CommandSender sender) throws CommandException { Audience audience = audiences.get(sender); audience.sendMessage(new Component("Pushing " + mapLibrary.getMaps().size() + " maps...")); syncExecutor.callback( mapLibrary.pushAllMaps(), CommandFutureCallback.onSuccess(sender, args, response -> audience.sendMessage(new Component(response.toString())) ) ); } @Command( aliases = {"environment", "env"}, desc = "Get/set map environment variables" ) @CommandPermissions(Permissions.MAPDEV) public void environment(CommandContext args, final CommandSender sender) throws CommandException { final Audience audience = audiences.get(sender); boolean reset = false; Map<String, Boolean> vars = new HashMap<>(); Pattern pattern = Pattern.compile("([A-Za-z0-9_]+)=(true|false)"); for(int i = 0; i < args.argsLength(); i++) { String arg = args.getString(i); if("reset".equals(arg)) { reset = true; } else { Matcher matcher = pattern.matcher(arg); if(!matcher.matches()) { throw new CommandException("Can't understand variable assignment '" + arg + "'"); } vars.put(matcher.group(1).toLowerCase(), "true".equals(matcher.group(2))); } } if(reset) mapEnvironment.clear(); mapEnvironment.putAll(vars); for(Map.Entry<String, Boolean> entry : mapEnvironment.entrySet()) { audience.sendMessage( new Component(ChatColor.GRAY) .extra(new Component(entry.getKey(), ChatColor.WHITE)) .extra("=") .extra(new Component(String.valueOf(entry.getValue()), ChatColor.BLUE)) ); } } @Command( aliases = {"debugvelocity"}, desc = "Dump debug info about a player's velocity to the console", usage = "[player]", min = 0, max = 1 ) @CommandPermissions(Permissions.MAPDEV) public void debugVelocity(CommandContext args, CommandSender sender) throws CommandException { final MatchPlayer player = tc.oc.pgm.commands.CommandUtils.getMatchPlayerOrSelf(args, sender, 0); final DebugVelocityPlayerFacet facet = player.facet(DebugVelocityPlayerFacet.class); final boolean enabled = !facet.isEnabled(); facet.setEnabled(enabled); sender.sendMessage(new Component("Velocity debug for " + player.getName() + " is " + (enabled ? "ENABLED" : "DISABLED"))); } }