diff --git a/core/src/main/java/tc/oc/pgm/PGMConfig.java b/core/src/main/java/tc/oc/pgm/PGMConfig.java index a412ccb24c..bfed54c0ba 100644 --- a/core/src/main/java/tc/oc/pgm/PGMConfig.java +++ b/core/src/main/java/tc/oc/pgm/PGMConfig.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.TreeSet; import java.util.logging.Level; +import javax.annotation.Nullable; import net.kyori.adventure.text.Component; import org.bukkit.ChatColor; import org.bukkit.configuration.ConfigurationSection; @@ -56,6 +57,7 @@ public final class PGMConfig implements Config { // map.* private final List mapSourceFactories; private final String mapPoolFile; + private final String includesDirectory; // countdown.* private final Duration startTime; @@ -159,6 +161,11 @@ public final class PGMConfig implements Config { mapPoolFile == null || mapPoolFile.isEmpty() ? null : new File(dataFolder, mapPoolFile).getAbsolutePath(); + final String includesDirectory = config.getString("map.includes"); + this.includesDirectory = + includesDirectory == null || includesDirectory.isEmpty() + ? null + : new File(dataFolder, includesDirectory).getAbsolutePath(); this.startTime = parseDuration(config.getString("countdown.start", "30s")); this.huddleTime = parseDuration(config.getString("countdown.huddle", "0s")); @@ -466,6 +473,11 @@ public String getMapPoolFile() { return mapPoolFile; } + @Override + public @Nullable String getIncludesDirectory() { + return includesDirectory; + } + @Override public Duration getStartTime() { return startTime; diff --git a/core/src/main/java/tc/oc/pgm/PGMPlugin.java b/core/src/main/java/tc/oc/pgm/PGMPlugin.java index 6f27b0a109..5457751e66 100644 --- a/core/src/main/java/tc/oc/pgm/PGMPlugin.java +++ b/core/src/main/java/tc/oc/pgm/PGMPlugin.java @@ -36,6 +36,7 @@ import tc.oc.pgm.api.map.MapOrder; import tc.oc.pgm.api.map.exception.MapException; import tc.oc.pgm.api.map.factory.MapSourceFactory; +import tc.oc.pgm.api.map.includes.MapIncludeProcessor; import tc.oc.pgm.api.match.Match; import tc.oc.pgm.api.match.MatchManager; import tc.oc.pgm.api.module.Module; @@ -57,6 +58,7 @@ import tc.oc.pgm.listeners.ServerPingDataListener; import tc.oc.pgm.listeners.WorldProblemListener; import tc.oc.pgm.map.MapLibraryImpl; +import tc.oc.pgm.map.includes.MapIncludeProcessorImpl; import tc.oc.pgm.match.MatchManagerImpl; import tc.oc.pgm.match.NoopVanishManager; import tc.oc.pgm.namedecorations.ConfigDecorationProvider; @@ -85,6 +87,7 @@ public class PGMPlugin extends JavaPlugin implements PGM, Listener { private Logger gameLogger; private Datastore datastore; private MapLibrary mapLibrary; + private MapIncludeProcessor mapIncludeProcessor; private List mapSourceFactories; private MatchManager matchManager; private MatchTabManager matchTabManager; @@ -134,7 +137,8 @@ public void onEnable() { asyncExecutorService = new BukkitExecutorService(this, true); mapSourceFactories = new ArrayList<>(); - mapLibrary = new MapLibraryImpl(gameLogger, mapSourceFactories); + mapIncludeProcessor = new MapIncludeProcessorImpl(gameLogger); + mapLibrary = new MapLibraryImpl(gameLogger, mapSourceFactories, mapIncludeProcessor); saveDefaultConfig(); // Writes a config file, if one does not exist. reloadConfig(); // Populates "this.config", if there is an error, will be null @@ -261,6 +265,7 @@ public void reloadConfig() { mapSourceFactories.clear(); mapSourceFactories.addAll(config.getMapSourceFactories()); + mapIncludeProcessor.reload(config); if (mapOrder != null) { mapOrder.reload(); diff --git a/core/src/main/java/tc/oc/pgm/api/Config.java b/core/src/main/java/tc/oc/pgm/api/Config.java index c5d171c476..79bcc4f262 100644 --- a/core/src/main/java/tc/oc/pgm/api/Config.java +++ b/core/src/main/java/tc/oc/pgm/api/Config.java @@ -60,6 +60,14 @@ public interface Config { @Nullable String getMapPoolFile(); + /** + * Gets a path to the includes directory. + * + * @return A path to the includes directory, or null for none. + */ + @Nullable + String getIncludesDirectory(); + /** * Gets a duration to wait before starting a match. * diff --git a/core/src/main/java/tc/oc/pgm/api/map/MapLibrary.java b/core/src/main/java/tc/oc/pgm/api/map/MapLibrary.java index c985038b35..5161d8742b 100644 --- a/core/src/main/java/tc/oc/pgm/api/map/MapLibrary.java +++ b/core/src/main/java/tc/oc/pgm/api/map/MapLibrary.java @@ -3,6 +3,7 @@ import java.util.Iterator; import java.util.concurrent.CompletableFuture; import javax.annotation.Nullable; +import tc.oc.pgm.api.map.includes.MapIncludeProcessor; /** A library of {@link MapInfo}s and {@link MapContext}s. */ public interface MapLibrary { @@ -45,4 +46,11 @@ public interface MapLibrary { * @return A {@link MapContext}. */ CompletableFuture loadExistingMap(String id); + + /** + * Get the {@link MapIncludeProcessor}. + * + * @return A {@link MapIncludeProcessor} + */ + MapIncludeProcessor getIncludeProcessor(); } diff --git a/core/src/main/java/tc/oc/pgm/api/map/MapSource.java b/core/src/main/java/tc/oc/pgm/api/map/MapSource.java index f65c4fd22c..4a96615342 100644 --- a/core/src/main/java/tc/oc/pgm/api/map/MapSource.java +++ b/core/src/main/java/tc/oc/pgm/api/map/MapSource.java @@ -3,6 +3,7 @@ import java.io.File; import java.io.InputStream; import tc.oc.pgm.api.map.exception.MapMissingException; +import tc.oc.pgm.api.map.includes.MapInclude; /** A source where {@link MapInfo} documents and files are downloaded. */ public interface MapSource { @@ -38,4 +39,14 @@ public interface MapSource { * @throws MapMissingException If the document can no longer be found. */ boolean checkForUpdates() throws MapMissingException; + + /** + * Adds an associated {@link MapInclude} + * + * @param include The {@link MapInclude} + */ + void addMapInclude(MapInclude include); + + /** Remove all associated {@link MapInclude}, used when reloading document. */ + void clearIncludes(); } diff --git a/core/src/main/java/tc/oc/pgm/api/map/includes/MapInclude.java b/core/src/main/java/tc/oc/pgm/api/map/includes/MapInclude.java new file mode 100644 index 0000000000..e9a3df7e82 --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/api/map/includes/MapInclude.java @@ -0,0 +1,37 @@ +package tc.oc.pgm.api.map.includes; + +import java.util.Collection; +import org.jdom2.Content; + +/** Represents a snippet of XML that can be referenced for reuse * */ +public interface MapInclude { + + /** + * Get a unique id which identifies this MapInclude. + * + * @return A unique id + */ + String getId(); + + /** + * Get the system file time from when this MapInclude file was last modified. + * + * @return Time of last file modification + */ + long getLastModified(); + + /** + * Gets whether the associated {@link MapInclude} files have changed since last loading. + * + * @param time The current system time + * @return True if given time is newer than last modified time + */ + boolean hasBeenModified(long time); + + /** + * Get a collection of {@link Content} which can be merged into an existing {@link Document} + * + * @return a collection of {@link Content} + */ + Collection getContent(); +} diff --git a/core/src/main/java/tc/oc/pgm/api/map/includes/MapIncludeProcessor.java b/core/src/main/java/tc/oc/pgm/api/map/includes/MapIncludeProcessor.java new file mode 100644 index 0000000000..58d5658154 --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/api/map/includes/MapIncludeProcessor.java @@ -0,0 +1,34 @@ +package tc.oc.pgm.api.map.includes; + +import java.util.Collection; +import org.jdom2.Document; +import tc.oc.pgm.api.Config; +import tc.oc.pgm.util.xml.InvalidXMLException; + +/** A processor to determine which {@link MapInclude}s should be included when loading a map * */ +public interface MapIncludeProcessor { + + /** + * Process the given {@link Document} and return a collection of {@link MapInclude}s. + * + * @param document A map document + * @return A collection of map includes, collection will be empty if none are found. + * @throws InvalidXMLException If the given document is not found or able to be parsed. + */ + Collection getMapIncludes(Document document) throws InvalidXMLException; + + /** + * Get a {@link MapInclude} by its id + * + * @param includeId ID of the map include + * @return A {@link MapInclude} + */ + MapInclude getMapIncludeById(String includeId); + + /** + * Reload the processor to fetch new map includes or reload existing ones. + * + * @param config A configuration file. + */ + void reload(Config config); +} diff --git a/core/src/main/java/tc/oc/pgm/map/MapFactoryImpl.java b/core/src/main/java/tc/oc/pgm/map/MapFactoryImpl.java index eec262cdf7..efcce812df 100644 --- a/core/src/main/java/tc/oc/pgm/map/MapFactoryImpl.java +++ b/core/src/main/java/tc/oc/pgm/map/MapFactoryImpl.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.io.InputStream; +import java.util.Collection; import java.util.logging.Logger; import org.jdom2.Document; import org.jdom2.JDOMException; @@ -19,6 +20,8 @@ import tc.oc.pgm.api.map.exception.MapMissingException; import tc.oc.pgm.api.map.factory.MapFactory; import tc.oc.pgm.api.map.factory.MapModuleFactory; +import tc.oc.pgm.api.map.includes.MapInclude; +import tc.oc.pgm.api.map.includes.MapIncludeProcessor; import tc.oc.pgm.api.module.ModuleGraph; import tc.oc.pgm.api.module.exception.ModuleLoadException; import tc.oc.pgm.features.FeatureDefinitionContext; @@ -49,6 +52,7 @@ public class MapFactoryImpl extends ModuleGraph mapIncludes = includes.getMapIncludes(document); + for (MapInclude include : mapIncludes) { + document.getRootElement().addContent(0, include.getContent()); + storeInclude(include); + } + info = new MapInfoImpl(document.getRootElement()); } diff --git a/core/src/main/java/tc/oc/pgm/map/MapLibraryImpl.java b/core/src/main/java/tc/oc/pgm/map/MapLibraryImpl.java index d4cd9826b5..6df17ed67a 100644 --- a/core/src/main/java/tc/oc/pgm/map/MapLibraryImpl.java +++ b/core/src/main/java/tc/oc/pgm/map/MapLibraryImpl.java @@ -28,6 +28,7 @@ import tc.oc.pgm.api.map.exception.MapMissingException; import tc.oc.pgm.api.map.factory.MapFactory; import tc.oc.pgm.api.map.factory.MapSourceFactory; +import tc.oc.pgm.api.map.includes.MapIncludeProcessor; import tc.oc.pgm.util.StringUtils; import tc.oc.pgm.util.UsernameResolver; @@ -37,6 +38,7 @@ public class MapLibraryImpl implements MapLibrary { private final List factories; private final SortedMap maps; private final Set failed; + private final MapIncludeProcessor includes; private static class MapEntry { private final MapSource source; @@ -50,11 +52,13 @@ private MapEntry(MapSource source, MapInfo info, MapContext context) { } } - public MapLibraryImpl(Logger logger, List factories) { + public MapLibraryImpl( + Logger logger, List factories, MapIncludeProcessor includes) { this.logger = checkNotNull(logger); // Logger should be visible in-game this.factories = Collections.synchronizedList(checkNotNull(factories)); this.maps = Collections.synchronizedSortedMap(new ConcurrentSkipListMap<>()); this.failed = Collections.synchronizedSet(new HashSet<>()); + this.includes = includes; } @Override @@ -79,6 +83,11 @@ public long getSize() { return maps.size(); } + @Override + public MapIncludeProcessor getIncludeProcessor() { + return includes; + } + private void logMapError(MapException err) { logger.log(Level.WARNING, err.getMessage(), err); } @@ -187,7 +196,7 @@ public CompletableFuture loadExistingMap(String id) { private MapContext loadMap(MapSource source, @Nullable String mapId) throws MapException { final MapContext context; - try (final MapFactory factory = new MapFactoryImpl(logger, source)) { + try (final MapFactory factory = new MapFactoryImpl(logger, source, includes)) { context = factory.load(); } catch (MapMissingException e) { failed.remove(source); diff --git a/core/src/main/java/tc/oc/pgm/map/includes/MapIncludeImpl.java b/core/src/main/java/tc/oc/pgm/map/includes/MapIncludeImpl.java new file mode 100644 index 0000000000..d608e47f4a --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/map/includes/MapIncludeImpl.java @@ -0,0 +1,59 @@ +package tc.oc.pgm.map.includes; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; +import java.util.concurrent.atomic.AtomicLong; +import org.jdom2.Content; +import org.jdom2.Document; +import org.jdom2.JDOMException; +import tc.oc.pgm.api.map.exception.MapMissingException; +import tc.oc.pgm.api.map.includes.MapInclude; + +public class MapIncludeImpl implements MapInclude { + + private final String id; + private final Document source; + private final AtomicLong lastModified; + + public MapIncludeImpl(File file) throws MapMissingException, JDOMException, IOException { + try { + InputStream fileStream = new FileInputStream(file); + this.id = file.getName().replace(".xml", ""); + this.source = MapIncludeProcessorImpl.DOCUMENT_FACTORY.get().build(fileStream); + } catch (FileNotFoundException e) { + throw new MapMissingException(file.getPath(), "Unable to read map include document", e); + } finally { + lastModified = new AtomicLong(file.lastModified()); + } + } + + @Override + public String getId() { + return id; + } + + @Override + public Collection getContent() { + return source.getRootElement().cloneContent(); + } + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof MapInclude)) return false; + return ((MapInclude) other).getId().equalsIgnoreCase(getId()); + } + + @Override + public long getLastModified() { + return lastModified.get(); + } + + @Override + public boolean hasBeenModified(long time) { + return time > lastModified.get(); + } +} diff --git a/core/src/main/java/tc/oc/pgm/map/includes/MapIncludeProcessorImpl.java b/core/src/main/java/tc/oc/pgm/map/includes/MapIncludeProcessorImpl.java new file mode 100644 index 0000000000..269bbaf6c7 --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/map/includes/MapIncludeProcessorImpl.java @@ -0,0 +1,107 @@ +package tc.oc.pgm.map.includes; + +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import java.io.File; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; +import org.jdom2.Document; +import org.jdom2.Element; +import org.jdom2.JDOMException; +import org.jdom2.input.SAXBuilder; +import tc.oc.pgm.api.Config; +import tc.oc.pgm.api.map.exception.MapMissingException; +import tc.oc.pgm.api.map.includes.MapInclude; +import tc.oc.pgm.api.map.includes.MapIncludeProcessor; +import tc.oc.pgm.util.xml.InvalidXMLException; +import tc.oc.pgm.util.xml.Node; +import tc.oc.pgm.util.xml.SAXHandler; +import tc.oc.pgm.util.xml.XMLUtils; + +public class MapIncludeProcessorImpl implements MapIncludeProcessor { + + private final Logger logger; + private final Map includes; + + protected static final ThreadLocal DOCUMENT_FACTORY = + ThreadLocal.withInitial( + () -> { + final SAXBuilder builder = new SAXBuilder(); + builder.setSAXHandlerFactory(SAXHandler.FACTORY); + return builder; + }); + + public MapIncludeProcessorImpl(Logger logger) { + this.logger = logger; + this.includes = Maps.newHashMap(); + } + + public MapInclude getGlobalInclude() { + return getMapIncludeById("global"); + } + + @Override + public MapInclude getMapIncludeById(String includeId) { + return includes.get(includeId); + } + + @Override + public Collection getMapIncludes(Document document) throws InvalidXMLException { + Set mapIncludes = Sets.newHashSet(); + + // Always add global include if present + if (getGlobalInclude() != null) { + mapIncludes.add(getGlobalInclude()); + } + + List elements = document.getRootElement().getChildren("include"); + for (Element element : elements) { + + if (Node.fromAttr(element, "src") != null) { + // Send a warning to legacy include statements without preventing them from loading + logger.warning( + "[" + + document.getBaseURI() + + "] " + + "Legacy include statements are no longer supported, please upgrade to the format."); + continue; + } + + String id = XMLUtils.getRequiredAttribute(element, "id").getValue(); + MapInclude include = getMapIncludeById(id); + if (include == null) + throw new InvalidXMLException( + "The provided include id '" + id + "' could not be found!", element); + + mapIncludes.add(include); + } + return mapIncludes; + } + + @Override + public void reload(Config config) { + this.includes.clear(); + + if (config.getIncludesDirectory() == null) return; + + File includeFiles = new File(config.getIncludesDirectory()); + if (!includeFiles.isDirectory()) { + logger.warning(config.getIncludesDirectory() + " is not a directory!"); + return; + } + File[] files = includeFiles.listFiles(); + for (File file : files) { + try { + MapIncludeImpl include = new MapIncludeImpl(file); + this.includes.put(include.getId(), include); + } catch (MapMissingException | JDOMException | IOException error) { + logger.info("Unable to load " + file.getName() + " include document"); + error.printStackTrace(); + } + } + } +} diff --git a/core/src/main/java/tc/oc/pgm/map/source/SystemMapSourceFactory.java b/core/src/main/java/tc/oc/pgm/map/source/SystemMapSourceFactory.java index 755ac89b01..cd4f5da1e0 100644 --- a/core/src/main/java/tc/oc/pgm/map/source/SystemMapSourceFactory.java +++ b/core/src/main/java/tc/oc/pgm/map/source/SystemMapSourceFactory.java @@ -2,6 +2,7 @@ import static com.google.common.base.Preconditions.checkNotNull; +import com.google.common.collect.Sets; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -10,11 +11,13 @@ import java.nio.file.FileVisitOption; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Stream; import org.apache.commons.lang3.builder.ToStringBuilder; import tc.oc.pgm.api.map.MapSource; import tc.oc.pgm.api.map.exception.MapMissingException; +import tc.oc.pgm.api.map.includes.MapInclude; import tc.oc.pgm.util.FileUtils; public class SystemMapSourceFactory extends PathMapSourceFactory { @@ -44,10 +47,12 @@ protected static class SystemMapSource implements MapSource { private final String dir; private final AtomicLong modified; + private final Set storedIncludes; private SystemMapSource(String dir) { this.dir = checkNotNull(dir); this.modified = new AtomicLong(-1); + this.storedIncludes = Sets.newHashSet(); } private File getDirectory() throws MapMissingException { @@ -118,7 +123,7 @@ public boolean checkForUpdates() throws MapMissingException { final File file = getFile(); final long mod = modified.get(); - return mod > 0 && file.lastModified() > mod; + return (mod > 0 && file.lastModified() > mod) || checkForIncludeUpdates(); } @Override @@ -139,5 +144,24 @@ public String toString() { .append("modified", modified.get()) .build(); } + + @Override + public void addMapInclude(MapInclude include) { + this.storedIncludes.add(include); + } + + @Override + public void clearIncludes() { + this.storedIncludes.clear(); + } + + private boolean checkForIncludeUpdates() { + for (MapInclude include : storedIncludes) { + if (include.hasBeenModified(include.getLastModified())) { + return true; + } + } + return false; + } } } diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 162d7e7a38..bbc139cd72 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -31,6 +31,9 @@ map: # A path to a map pools file, or empty to disable map pools. pools: "map-pools.yml" + + # A path to the includes folder, or empty to disable map includes. + includes: "includes" # Sets the duration of various countdowns. #