diff --git a/bukkit/src/main/java/net/pl3x/map/bukkit/Pl3xMapBukkit.java b/bukkit/src/main/java/net/pl3x/map/bukkit/Pl3xMapBukkit.java index 0f2b5ab8f..1837efa5d 100644 --- a/bukkit/src/main/java/net/pl3x/map/bukkit/Pl3xMapBukkit.java +++ b/bukkit/src/main/java/net/pl3x/map/bukkit/Pl3xMapBukkit.java @@ -81,7 +81,7 @@ public void onEnable() { } getServer().getScheduler().runTaskTimer(this, () -> - this.pl3xmap.getScheduler().tick(), 20, 20); + this.pl3xmap.getScheduler().tick(), 20, 1); } @Override diff --git a/core/src/main/java/net/pl3x/map/core/configuration/Config.java b/core/src/main/java/net/pl3x/map/core/configuration/Config.java index a0c4a2cae..88ee80025 100644 --- a/core/src/main/java/net/pl3x/map/core/configuration/Config.java +++ b/core/src/main/java/net/pl3x/map/core/configuration/Config.java @@ -118,6 +118,12 @@ How many scroll pixels (as reported by L.DomEvent.getWheelDelta) mean for security reasons. But you do you, boo boo.""") public static boolean HTTPD_FOLLOW_SYMLINKS = false; + @Key("settings.performance.live-update-threads") + @Comment(""" + The number of process-threads to use for real-time marker updates on the map. + Value of -1 will use 50% of the available cpu-threads. (recommended)""") + public static int LIVE_UPDATE_THREADS = -1; + @Key("settings.performance.render-threads") @Comment(""" The number of process-threads to use for loading and scanning chunks. diff --git a/core/src/main/java/net/pl3x/map/core/configuration/PlayersLayerConfig.java b/core/src/main/java/net/pl3x/map/core/configuration/PlayersLayerConfig.java index 7ccbdd848..81380d5d9 100644 --- a/core/src/main/java/net/pl3x/map/core/configuration/PlayersLayerConfig.java +++ b/core/src/main/java/net/pl3x/map/core/configuration/PlayersLayerConfig.java @@ -44,9 +44,12 @@ public final class PlayersLayerConfig extends AbstractConfig { @Key("settings.layer.update-interval") @Comment(""" - How often (in seconds) to update the marker. - Setting to 0 is the same as setting it to 1.""") + How often (in seconds) to update the marker.""") public static int UPDATE_INTERVAL = 0; + @Key("settings.layer.live-update") + @Comment(""" + Whether to push this layer through SSE or not.""") + public static boolean LIVE_UPDATE = true; @Key("settings.layer.show-controls") @Comment(""" Whether the players layer control shows up in the layers list or not.""") diff --git a/core/src/main/java/net/pl3x/map/core/configuration/SpawnLayerConfig.java b/core/src/main/java/net/pl3x/map/core/configuration/SpawnLayerConfig.java index 86c6f7a44..edc56b5b4 100644 --- a/core/src/main/java/net/pl3x/map/core/configuration/SpawnLayerConfig.java +++ b/core/src/main/java/net/pl3x/map/core/configuration/SpawnLayerConfig.java @@ -34,9 +34,12 @@ public final class SpawnLayerConfig extends AbstractConfig { @Key("settings.layer.update-interval") @Comment(""" - How often (in seconds) to update the marker. - Setting to 0 is the same as setting it to 1.""") + How often (in seconds) to update the marker.""") public static int UPDATE_INTERVAL = 30; + @Key("settings.layer.live-update") + @Comment(""" + Whether to push this layer through SSE or not.""") + public static boolean LIVE_UPDATE = true; @Key("settings.layer.show-controls") @Comment(""" Whether the spawn layer control shows up in the layers list or not.""") diff --git a/core/src/main/java/net/pl3x/map/core/configuration/WorldBorderLayerConfig.java b/core/src/main/java/net/pl3x/map/core/configuration/WorldBorderLayerConfig.java index b7f5c78f0..9fa92e96e 100644 --- a/core/src/main/java/net/pl3x/map/core/configuration/WorldBorderLayerConfig.java +++ b/core/src/main/java/net/pl3x/map/core/configuration/WorldBorderLayerConfig.java @@ -35,9 +35,12 @@ public final class WorldBorderLayerConfig extends AbstractConfig { @Key("settings.layer.update-interval") @Comment(""" - How often (in seconds) to update the marker. - Setting to 0 is the same as setting it to 1.""") + How often (in seconds) to update the marker.""") public static int UPDATE_INTERVAL = 30; + @Key("settings.layer.live-update") + @Comment(""" + Whether to push this layer through SSE or not.""") + public static boolean LIVE_UPDATE = true; @Key("settings.layer.show-controls") @Comment(""" Whether the vanilla world border layer control shows up in the layers list or not.""") diff --git a/core/src/main/java/net/pl3x/map/core/httpd/HttpdServer.java b/core/src/main/java/net/pl3x/map/core/httpd/HttpdServer.java index 46f022a58..1423a3409 100644 --- a/core/src/main/java/net/pl3x/map/core/httpd/HttpdServer.java +++ b/core/src/main/java/net/pl3x/map/core/httpd/HttpdServer.java @@ -23,26 +23,40 @@ */ package net.pl3x.map.core.httpd; +import io.undertow.Handlers; import io.undertow.Undertow; import io.undertow.UndertowLogger; import io.undertow.UndertowOptions; +import io.undertow.server.HttpServerExchange; import io.undertow.server.handlers.resource.PathResourceManager; import io.undertow.server.handlers.resource.ResourceHandler; import io.undertow.server.handlers.resource.ResourceManager; import io.undertow.util.ETag; import io.undertow.util.Headers; +import io.undertow.util.HttpString; +import io.undertow.util.StatusCodes; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.nio.file.attribute.BasicFileAttributes; +import java.util.stream.Collectors; +import net.pl3x.map.core.Pl3xMap; import net.pl3x.map.core.configuration.Config; import net.pl3x.map.core.configuration.Lang; import net.pl3x.map.core.log.LogFilter; import net.pl3x.map.core.log.Logger; +import net.pl3x.map.core.registry.WorldRegistry; import net.pl3x.map.core.util.FileUtil; +import net.pl3x.map.core.world.World; public class HttpdServer { + private HttpString X_ACCEL_BUFFERING = new HttpString("X-Accel-Buffering"); private Undertow server; + private LiveDataHandler liveDataHandler = new LiveDataHandler(); + + public LiveDataHandler getLiveDataHandler() { + return liveDataHandler; + } public void startServer() { if (!Config.HTTPD_ENABLED) { @@ -81,16 +95,48 @@ public void startServer() { this.server = Undertow.builder() .setServerOption(UndertowOptions.ENABLE_HTTP2, true) .addHttpListener(Config.HTTPD_PORT, Config.HTTPD_BIND) - .setHandler(exchange -> { - if (exchange.getRelativePath().startsWith("/tiles")) { - exchange.getResponseHeaders().put(Headers.CACHE_CONTROL, "max-age=0, must-revalidate, no-cache"); - } - if (exchange.getRelativePath().endsWith(".gz")) { - exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json"); - exchange.getResponseHeaders().put(Headers.CONTENT_ENCODING, "gzip"); - } - resourceHandler.handleRequest(exchange); - }) + .setHandler( + Handlers.path(exchange -> { + if (exchange.getRelativePath().startsWith("/tiles")) { + exchange.getResponseHeaders().put(Headers.CACHE_CONTROL, "max-age=0, must-revalidate, no-cache"); + } + if (exchange.getRelativePath().endsWith(".gz")) { + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json"); + exchange.getResponseHeaders().put(Headers.CONTENT_ENCODING, "gzip"); + } + resourceHandler.handleRequest(exchange); + }) + .addPrefixPath("/sse", + Handlers.pathTemplate() + .add("{world}", exchange -> { + String worldName = exchange.getQueryParameters().get("world").peek(); + if (worldName == null || worldName.isEmpty()) { + exchange.getResponseHeaders().put(X_ACCEL_BUFFERING, "no"); + liveDataHandler.handle(exchange); + return; + } + + WorldRegistry worldRegistry = Pl3xMap.api().getWorldRegistry(); + World world = worldRegistry.get(worldName); + if (world == null || !world.isEnabled()) { + String listOfValidWorlds = worldRegistry.values().stream() + .filter(World::isEnabled) + .map(World::getName).collect(Collectors.joining(", ")); + handleError(exchange, "Could not find world named '%s'. Available worlds: %s" + .formatted(worldName, listOfValidWorlds)); + exchange.endExchange(); + return; + } + + if (exchange.isInIoThread()) { + exchange.dispatch(world.getServerSentEventHandler().get()); + } else { + exchange.getResponseHeaders().put(X_ACCEL_BUFFERING, "no"); + world.getServerSentEventHandler().handle(exchange); + } + }) + ) + ) .build(); this.server.start(); LogFilter.HIDE_UNDERTOW_LOGS = false; @@ -105,6 +151,12 @@ public void startServer() { } } + private void handleError(HttpServerExchange exchange, String errorMessage) { + exchange.setStatusCode(StatusCodes.NOT_FOUND); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json"); + exchange.getResponseSender().send("{\"error\": \"" + errorMessage + "\"}"); + } + public void stopServer() { if (!Config.HTTPD_ENABLED) { return; @@ -116,6 +168,10 @@ public void stopServer() { } LogFilter.HIDE_UNDERTOW_LOGS = true; + this.liveDataHandler.closeConnections(); + Pl3xMap.api().getWorldRegistry().forEach(world -> { + world.getServerSentEventHandler().closeConnections(); + }); this.server.stop(); LogFilter.HIDE_UNDERTOW_LOGS = false; diff --git a/core/src/main/java/net/pl3x/map/core/httpd/LiveDataHandler.java b/core/src/main/java/net/pl3x/map/core/httpd/LiveDataHandler.java new file mode 100644 index 000000000..9130ad52b --- /dev/null +++ b/core/src/main/java/net/pl3x/map/core/httpd/LiveDataHandler.java @@ -0,0 +1,152 @@ +/* + * MIT License + * + * Copyright (c) 2020-2023 William Blake Galbreath + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.pl3x.map.core.httpd; + +import io.undertow.Handlers; +import io.undertow.server.HttpServerExchange; +import io.undertow.server.handlers.sse.ServerSentEventConnection; +import io.undertow.server.handlers.sse.ServerSentEventHandler; +import java.io.IOException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class LiveDataHandler { + private ServerSentEventHandler serverSentEventHandler; + + public LiveDataHandler() { + this.serverSentEventHandler = Handlers.serverSentEvents(); + } + + /** + * + * @param event The message event + * @param data The message data + * @param success The callback that is called when a message is sucessfully sent. + * @param failure The callback that is called when a message send fails. + */ + public void send(String event, String data, SuccessCallback success, FailureCallback failure) { + if (serverSentEventHandler == null) { + return; + } + + Callback callback = new Callback(success, failure); + for (ServerSentEventConnection connection : this.serverSentEventHandler.getConnections()) { + connection.send(data, event, null, callback); + } + } + + /** + * + * @param event The message event + * @param data The message data + * @param success The callback that is called when a message is sucessfully sent. + */ + public void send(String event, String data, SuccessCallback success) { + this.send(event, data, success, null); + } + + /** + * + * @param event The message event + * @param data The message data + */ + public void send(String event, String data) { + this.send(event, data, null, null); + } + + /** + * + * @param data The message data + */ + public void send(String data) { + this.send(null, data); + } + + public void closeConnections() { + for (ServerSentEventConnection connection : serverSentEventHandler.getConnections()) { + connection.shutdown(); + } + } + + public void handle(HttpServerExchange exchange) throws Exception { + this.serverSentEventHandler.handleRequest(exchange); + } + + public ServerSentEventHandler get() { + return this.serverSentEventHandler; + } + + /** + * Notification that is called when a message is sucessfully sent + */ + @FunctionalInterface + public interface SuccessCallback { + /** + * @param connection The connection + * @param data The message data + * @param event The message event + * @param id The message id + */ + void apply(@NotNull ServerSentEventConnection connection, @Nullable String data, @Nullable String event, @Nullable String id); + } + + /** + * Notification that is called when a message send fails. + */ + @FunctionalInterface + public interface FailureCallback { + /** + * @param connection The connection + * @param data The message data + * @param event The message event + * @param id The message id + * @param exception The exception + */ + void apply(@NotNull ServerSentEventConnection connection, @Nullable String data, @Nullable String event, @Nullable String id, @NotNull IOException exception); + } + + private class Callback implements ServerSentEventConnection.EventCallback { + private SuccessCallback success; + private FailureCallback failure; + + public Callback(SuccessCallback success, FailureCallback failure) { + this.success = success; + this.failure = failure; + } + + @Override + public void done(ServerSentEventConnection connection, String data, String event, String id) { + if (success != null) { + success.apply(connection, data, event, id); + } + } + + @Override + public void failed(ServerSentEventConnection connection, String data, String event, String id, IOException e) { + if (failure != null) { + failure.apply(connection, data, event, id, e); + } + } + } +} diff --git a/core/src/main/java/net/pl3x/map/core/markers/layer/Layer.java b/core/src/main/java/net/pl3x/map/core/markers/layer/Layer.java index b5f07c09b..3925fe1c4 100644 --- a/core/src/main/java/net/pl3x/map/core/markers/layer/Layer.java +++ b/core/src/main/java/net/pl3x/map/core/markers/layer/Layer.java @@ -32,6 +32,7 @@ import net.pl3x.map.core.markers.JsonSerializable; import net.pl3x.map.core.markers.marker.Marker; import net.pl3x.map.core.util.Preconditions; +import net.pl3x.map.core.util.TickUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -41,13 +42,14 @@ @SuppressWarnings("UnusedReturnValue") public abstract class Layer extends Keyed implements JsonSerializable { private Supplier<@NotNull String> labelSupplier; - private int updateInterval = 15; + private int updateInterval = TickUtil.toTicks(15); private boolean showControls = true; private boolean defaultHidden = false; private int priority = 99; private Integer zIndex = 99; private String pane; private String css; + private boolean liveUpdate = false; /** * Create a layer. @@ -98,7 +100,17 @@ public Layer(@NotNull String key, @NotNull Supplier<@NotNull String> labelSuppli * @return update interval */ public int getUpdateInterval() { - return this.updateInterval; + return this.getUpdateInterval(false); + } + + /** + * Get this layer's update interval (in seconds or in ticks). + * + * @param ticks set to true to get update interval as ticks instead of seconds + * @return update interval + */ + public int getUpdateInterval(boolean ticks) { + return ticks ? this.updateInterval : (int) TickUtil.toSeconds(this.updateInterval); } /** @@ -108,7 +120,19 @@ public int getUpdateInterval() { * @return this layer */ public @NotNull Layer setUpdateInterval(int updateInterval) { - this.updateInterval = updateInterval; + this.setUpdateInterval(updateInterval, false); + return this; + } + + /** + * Set this layer's update interval (in seconds or in ticks). + * + * @param updateInterval new update interval + * @param ticks set to true to treat the interval value as ticks instead of seconds + * @return this layer + */ + public @NotNull Layer setUpdateInterval(int updateInterval, boolean ticks) { + this.updateInterval = ticks ? updateInterval : TickUtil.toTicks(updateInterval); return this; } @@ -246,6 +270,26 @@ public int getPriority() { return this; } + /** + * Get if this layer gets pushed through sse. + * + * @return true if being sent through sse + */ + public @Nullable boolean isLiveUpdate() { + return this.liveUpdate; + } + + /** + * Set whether to push this layer through sse. + * + * @param liveUpdate true to push this layer through sse. + * @return this layer + */ + public @NotNull Layer setLiveUpdate(@Nullable boolean liveUpdate) { + this.liveUpdate = liveUpdate; + return this; + } + /** * Get the markers to display in this Layer. * diff --git a/core/src/main/java/net/pl3x/map/core/markers/layer/PlayersLayer.java b/core/src/main/java/net/pl3x/map/core/markers/layer/PlayersLayer.java index bf01e7e30..c4ca32573 100644 --- a/core/src/main/java/net/pl3x/map/core/markers/layer/PlayersLayer.java +++ b/core/src/main/java/net/pl3x/map/core/markers/layer/PlayersLayer.java @@ -60,6 +60,7 @@ public class PlayersLayer extends WorldLayer { public PlayersLayer(@NotNull World world) { this(KEY, world, () -> Lang.UI_LAYER_PLAYERS); setUpdateInterval(PlayersLayerConfig.UPDATE_INTERVAL); + setLiveUpdate(PlayersLayerConfig.LIVE_UPDATE); setShowControls(PlayersLayerConfig.SHOW_CONTROLS); setDefaultHidden(PlayersLayerConfig.DEFAULT_HIDDEN); setPriority(PlayersLayerConfig.PRIORITY); diff --git a/core/src/main/java/net/pl3x/map/core/markers/layer/SpawnLayer.java b/core/src/main/java/net/pl3x/map/core/markers/layer/SpawnLayer.java index 2114e589a..e27635a0c 100644 --- a/core/src/main/java/net/pl3x/map/core/markers/layer/SpawnLayer.java +++ b/core/src/main/java/net/pl3x/map/core/markers/layer/SpawnLayer.java @@ -66,6 +66,7 @@ public SpawnLayer(@NotNull World world) { } setUpdateInterval(SpawnLayerConfig.UPDATE_INTERVAL); + setLiveUpdate(SpawnLayerConfig.LIVE_UPDATE); setShowControls(SpawnLayerConfig.SHOW_CONTROLS); setDefaultHidden(SpawnLayerConfig.DEFAULT_HIDDEN); setPriority(SpawnLayerConfig.PRIORITY); diff --git a/core/src/main/java/net/pl3x/map/core/markers/layer/WorldBorderLayer.java b/core/src/main/java/net/pl3x/map/core/markers/layer/WorldBorderLayer.java index 33d8e97bd..753d27818 100644 --- a/core/src/main/java/net/pl3x/map/core/markers/layer/WorldBorderLayer.java +++ b/core/src/main/java/net/pl3x/map/core/markers/layer/WorldBorderLayer.java @@ -53,6 +53,7 @@ public class WorldBorderLayer extends WorldLayer { public WorldBorderLayer(@NotNull World world) { this(KEY, world, () -> Lang.UI_LAYER_WORLDBORDER); setUpdateInterval(WorldBorderLayerConfig.UPDATE_INTERVAL); + setLiveUpdate(WorldBorderLayerConfig.LIVE_UPDATE); setShowControls(WorldBorderLayerConfig.SHOW_CONTROLS); setDefaultHidden(WorldBorderLayerConfig.DEFAULT_HIDDEN); setPriority(WorldBorderLayerConfig.PRIORITY); diff --git a/core/src/main/java/net/pl3x/map/core/player/PlayerRegistry.java b/core/src/main/java/net/pl3x/map/core/player/PlayerRegistry.java index 820ec23a1..7a8b032ca 100644 --- a/core/src/main/java/net/pl3x/map/core/player/PlayerRegistry.java +++ b/core/src/main/java/net/pl3x/map/core/player/PlayerRegistry.java @@ -23,10 +23,17 @@ */ package net.pl3x.map.core.player; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.function.Supplier; +import net.pl3x.map.core.Pl3xMap; +import net.pl3x.map.core.configuration.PlayersLayerConfig; import net.pl3x.map.core.registry.Registry; import net.pl3x.map.core.util.Preconditions; import org.jetbrains.annotations.NotNull; @@ -89,4 +96,34 @@ public class PlayerRegistry extends Registry<@NotNull Player> { Player player = get(uuid); return player == null ? Optional.empty() : Optional.of(player); } + + public @NotNull List<@NotNull Object> parsePlayers() { + if (!PlayersLayerConfig.ENABLED) { + return Collections.emptyList(); + } + List players = new ArrayList<>(); + Pl3xMap.api().getPlayerRegistry().forEach(player -> { + // do not expose hidden players in the json + if (player.isHidden() || player.isNPC()) { + return; + } + if (PlayersLayerConfig.HIDE_SPECTATORS && player.isSpectator()) { + return; + } + if (PlayersLayerConfig.HIDE_INVISIBLE && player.isInvisible()) { + return; + } + + Map playerEntry = new LinkedHashMap<>(); + + playerEntry.put("name", player.getDecoratedName()); + playerEntry.put("uuid", player.getUUID().toString()); + playerEntry.put("displayName", player.getDecoratedName()); + playerEntry.put("world", player.getWorld().getName()); + playerEntry.put("position", player.getPosition()); + + players.add(playerEntry); + }); + return players; + } } diff --git a/core/src/main/java/net/pl3x/map/core/registry/WorldRegistry.java b/core/src/main/java/net/pl3x/map/core/registry/WorldRegistry.java index a1c58694e..732ea5e6c 100644 --- a/core/src/main/java/net/pl3x/map/core/registry/WorldRegistry.java +++ b/core/src/main/java/net/pl3x/map/core/registry/WorldRegistry.java @@ -46,6 +46,7 @@ public class WorldRegistry extends Registry<@NotNull World> { if (world != null) { Pl3xMap.api().getEventRegistry().callEvent(new WorldUnloadedEvent(world)); world.getMarkerTask().cancel(); + world.getLiveDataTask().cancel(); //world.getRegionFileWatcher().stop(); world.cleanup(); } diff --git a/core/src/main/java/net/pl3x/map/core/renderer/task/AbstractDataTask.java b/core/src/main/java/net/pl3x/map/core/renderer/task/AbstractDataTask.java new file mode 100644 index 000000000..1bfc1aa9e --- /dev/null +++ b/core/src/main/java/net/pl3x/map/core/renderer/task/AbstractDataTask.java @@ -0,0 +1,111 @@ +/* + * MIT License + * + * Copyright (c) 2020-2023 William Blake Galbreath + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.pl3x.map.core.renderer.task; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import net.pl3x.map.core.Pl3xMap; +import net.pl3x.map.core.log.Logger; +import net.pl3x.map.core.markers.JsonObjectWrapper; +import net.pl3x.map.core.markers.marker.Marker; +import net.pl3x.map.core.scheduler.Task; +import net.pl3x.map.core.world.World; +import org.jetbrains.annotations.NotNull; + +public abstract class AbstractDataTask extends Task { + protected final Gson gson = new GsonBuilder() + //.setPrettyPrinting() + .disableHtmlEscaping() + .serializeNulls() + .setLenient() + .registerTypeHierarchyAdapter(Marker.class, new Adapter()) + .create(); + + protected final World world; + protected final Map<@NotNull String, @NotNull Long> lastUpdated = new HashMap<>(); + protected final ExecutorService executor; + protected final String executorName; + + protected CompletableFuture future; + protected boolean running; + + public AbstractDataTask(int delay, boolean repeat, World world, String serviceName, int threads) { + super(delay, repeat); + this.world = world; + this.executor = Pl3xMap.ThreadFactory.createService(serviceName, threads); + this.executorName = serviceName; + } + + public AbstractDataTask(int delay, boolean repeat, World world, String serviceName) { + super(delay, repeat); + this.world = world; + this.executor = Pl3xMap.ThreadFactory.createService(serviceName); + this.executorName = serviceName; + } + + @Override + public void run() { + if (this.running) { + return; + } + this.running = true; + this.future = CompletableFuture.runAsync(() -> { + try { + parse(); + } catch (Throwable t) { + Logger.severe("Failed to parse task %s for world %s".formatted(executorName, world.getName()), t); + } + this.running = false; + }, this.executor); + } + + @Override + public void cancel() { + super.cancel(); + if (this.future != null) { + this.future.cancel(true); + } + } + + public abstract void parse(); + + protected static class Adapter implements JsonSerializer<@NotNull Marker> { + @Override + public @NotNull JsonElement serialize(@NotNull Marker marker, @NotNull Type type, @NotNull JsonSerializationContext context) { + JsonObjectWrapper wrapper = new JsonObjectWrapper(); + wrapper.addProperty("type", marker.getType()); + wrapper.addProperty("data", marker); + wrapper.addProperty("options", marker.getOptions()); + return wrapper.getJsonObject(); + } + } +} diff --git a/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateLiveData.java b/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateLiveData.java new file mode 100644 index 000000000..b50f15aa3 --- /dev/null +++ b/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateLiveData.java @@ -0,0 +1,94 @@ +/* + * MIT License + * + * Copyright (c) 2020-2023 William Blake Galbreath + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.pl3x.map.core.renderer.task; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import net.pl3x.map.core.Pl3xMap; +import net.pl3x.map.core.log.Logger; +import net.pl3x.map.core.markers.layer.Layer; +import net.pl3x.map.core.markers.marker.Marker; +import net.pl3x.map.core.world.World; +import org.jetbrains.annotations.NotNull; + +public class UpdateLiveData extends AbstractDataTask { + private final Cache<@NotNull String, Integer> markerCache = CacheBuilder.newBuilder() + .maximumSize(1000) + .expireAfterWrite(1, TimeUnit.MINUTES) + .build(); + private Map> liveUpdateFutures; + + public UpdateLiveData(@NotNull World world, int threads) { + super(1, true, world, "Pl3xMap-LiveData", threads); + this.liveUpdateFutures = new HashMap<>(); + } + + @Override + public void cancel() { + super.cancel(); + this.liveUpdateFutures.forEach((key, future) -> future.cancel(true)); + this.liveUpdateFutures.clear(); + } + + @Override + public void parse() { + this.world.getLayerRegistry().entrySet().forEach(entry -> { + String key = entry.getKey(); + Layer layer = entry.getValue(); + + if (!layer.isLiveUpdate()) { + return; + } + + CompletableFuture future = liveUpdateFutures.get(key); + if (future != null && !future.isDone()) { + return; + } + + this.liveUpdateFutures.put(key, CompletableFuture.runAsync(() -> { + try { + List> list = new ArrayList<>(layer.getMarkers()); + Integer markerCacheIfPresent = markerCache.getIfPresent(key); + int markerHashCode = list.hashCode(); + if (markerCacheIfPresent == null || !markerCacheIfPresent.equals(markerHashCode)) { + Logger.debug("[%s/%s] sending through sse %d".formatted(this.world.getName(), key, (System.currentTimeMillis()))); + world.getServerSentEventHandler().send("markers", String.format("{\"key\": \"%s\", \"markers\": %s}", key, this.gson.toJson(list))); + markerCache.put(key, markerHashCode); + } + } catch (Throwable t) { + Logger.debug("[%s/%s] failed".formatted(this.world.getName(), key)); + t.printStackTrace(); + } finally { + this.liveUpdateFutures.remove(key); + } + }, this.executor)); + }); + } +} diff --git a/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateMarkerData.java b/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateMarkerData.java index 65992667b..765f5714e 100644 --- a/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateMarkerData.java +++ b/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateMarkerData.java @@ -23,75 +23,23 @@ */ package net.pl3x.map.core.renderer.task; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonElement; -import com.google.gson.JsonSerializationContext; -import com.google.gson.JsonSerializer; -import java.lang.reflect.Type; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import net.pl3x.map.core.Pl3xMap; import net.pl3x.map.core.log.Logger; -import net.pl3x.map.core.markers.JsonObjectWrapper; import net.pl3x.map.core.markers.layer.Layer; import net.pl3x.map.core.markers.marker.Marker; -import net.pl3x.map.core.scheduler.Task; import net.pl3x.map.core.util.FileUtil; +import net.pl3x.map.core.util.TickUtil; import net.pl3x.map.core.world.World; import org.jetbrains.annotations.NotNull; -public class UpdateMarkerData extends Task { - private final Gson gson = new GsonBuilder() - //.setPrettyPrinting() - .disableHtmlEscaping() - .serializeNulls() - .setLenient() - .registerTypeHierarchyAdapter(Marker.class, new Adapter()) - .create(); - - private final World world; - private final Map<@NotNull String, @NotNull Long> lastUpdated = new HashMap<>(); - private final ExecutorService executor; - - private CompletableFuture future; - private boolean running; - +public class UpdateMarkerData extends AbstractDataTask { public UpdateMarkerData(@NotNull World world) { - super(1, true); - this.world = world; - this.executor = Pl3xMap.ThreadFactory.createService("Pl3xMap-Markers"); + super(TickUtil.toTicks(1), true, world, "Pl3xMap-Markers"); } @Override - public void run() { - if (this.running) { - return; - } - this.running = true; - this.future = CompletableFuture.runAsync(() -> { - try { - parseLayers(); - } catch (Throwable t) { - Logger.severe("Failed to parse Layers", t); - } - this.running = false; - }, this.executor); - } - - @Override - public void cancel() { - super.cancel(); - if (this.future != null) { - this.future.cancel(true); - } - } - - private void parseLayers() { + public void parse() { List layers = new ArrayList<>(); this.world.getLayerRegistry().entrySet().forEach(entry -> { @@ -100,10 +48,10 @@ private void parseLayers() { try { layers.add(layer.toJson()); - long now = System.currentTimeMillis() / 1000; - long lastUpdate = this.lastUpdated.getOrDefault(key, 0L); + long now = System.currentTimeMillis(); + long lastUpdated = this.lastUpdated.getOrDefault(key, 0L); - if (now - lastUpdate > layer.getUpdateInterval()) { + if (now - lastUpdated > Math.max(TickUtil.toMilliseconds(layer.getUpdateInterval()), 1000)) { List> list = new ArrayList<>(layer.getMarkers()); FileUtil.writeJson(this.gson.toJson(list), this.world.getMarkersDirectory().resolve(key.replace(":", "-") + ".json")); this.lastUpdated.put(key, now); @@ -115,15 +63,4 @@ private void parseLayers() { FileUtil.writeJson(this.gson.toJson(layers), this.world.getTilesDirectory().resolve("markers.json")); } - - private static class Adapter implements JsonSerializer<@NotNull Marker> { - @Override - public @NotNull JsonElement serialize(@NotNull Marker marker, @NotNull Type type, @NotNull JsonSerializationContext context) { - JsonObjectWrapper wrapper = new JsonObjectWrapper(); - wrapper.addProperty("type", marker.getType()); - wrapper.addProperty("data", marker); - wrapper.addProperty("options", marker.getOptions()); - return wrapper.getJsonObject(); - } - } } diff --git a/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateSettingsData.java b/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateSettingsData.java index cf36ac4d8..76e4a42a7 100644 --- a/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateSettingsData.java +++ b/core/src/main/java/net/pl3x/map/core/renderer/task/UpdateSettingsData.java @@ -26,7 +26,6 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; @@ -34,7 +33,6 @@ import net.pl3x.map.core.Pl3xMap; import net.pl3x.map.core.configuration.Config; import net.pl3x.map.core.configuration.Lang; -import net.pl3x.map.core.configuration.PlayersLayerConfig; import net.pl3x.map.core.configuration.WorldConfig; import net.pl3x.map.core.image.io.IO; import net.pl3x.map.core.log.Logger; @@ -45,12 +43,14 @@ import org.jetbrains.annotations.NotNull; public class UpdateSettingsData extends Task { + private int fileTick; private final Gson gson = new GsonBuilder() //.setPrettyPrinting() .disableHtmlEscaping() .serializeNulls() .setLenient() .create(); + private int jsonHashCache = -1; public UpdateSettingsData() { super(1, true); @@ -65,36 +65,6 @@ public void run() { } } - private @NotNull List<@NotNull Object> parsePlayers() { - if (!PlayersLayerConfig.ENABLED) { - return Collections.emptyList(); - } - List players = new ArrayList<>(); - Pl3xMap.api().getPlayerRegistry().forEach(player -> { - // do not expose hidden players in the json - if (player.isHidden() || player.isNPC()) { - return; - } - if (PlayersLayerConfig.HIDE_SPECTATORS && player.isSpectator()) { - return; - } - if (PlayersLayerConfig.HIDE_INVISIBLE && player.isInvisible()) { - return; - } - - Map playerEntry = new LinkedHashMap<>(); - - playerEntry.put("name", player.getDecoratedName()); - playerEntry.put("uuid", player.getUUID().toString()); - playerEntry.put("displayName", player.getDecoratedName()); - playerEntry.put("world", player.getWorld().getName()); - playerEntry.put("position", player.getPosition()); - - players.add(playerEntry); - }); - return players; - } - private @NotNull List<@NotNull Map<@NotNull String, @NotNull Object>> parseWorlds() { List> worldSettings = new ArrayList<>(); Pl3xMap.api().getWorldRegistry().entrySet().forEach(entry -> { @@ -185,12 +155,22 @@ private void parseSettings() { map.put("zoom", zoom); try { - map.put("players", parsePlayers()); + map.put("players", Pl3xMap.api().getPlayerRegistry().parsePlayers()); map.put("worldSettings", parseWorlds()); } catch (Throwable t) { Logger.severe("Failed to parse players and worlds settings", t); } - FileUtil.writeJson(this.gson.toJson(map), FileUtil.getTilesDir().resolve("settings.json")); + String json = this.gson.toJson(map); + + if (jsonHashCache != json.hashCode()) { + Pl3xMap.api().getHttpdServer().getLiveDataHandler().send("settings", json); + jsonHashCache = json.hashCode(); + } + + if (fileTick++ >= 20) { + fileTick = 0; + FileUtil.writeJson(json, FileUtil.getTilesDir().resolve("settings.json")); + } } } diff --git a/core/src/main/java/net/pl3x/map/core/scheduler/Scheduler.java b/core/src/main/java/net/pl3x/map/core/scheduler/Scheduler.java index ed878d465..c7149e0cc 100644 --- a/core/src/main/java/net/pl3x/map/core/scheduler/Scheduler.java +++ b/core/src/main/java/net/pl3x/map/core/scheduler/Scheduler.java @@ -27,6 +27,7 @@ import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; import net.pl3x.map.core.log.Logger; +import net.pl3x.map.core.util.TickUtil; import org.jetbrains.annotations.NotNull; public class Scheduler { @@ -35,7 +36,7 @@ public class Scheduler { private boolean ticking; /** - * Tick this scheduler once every second. + * Tick this scheduler once every tick. */ public void tick() { if (this.ticking) { @@ -101,15 +102,38 @@ public void addTask(int delay, @NotNull Runnable runnable) { addTask(delay, false, runnable); } + /** + * Add task to the scheduler. + * + * @param delay Delay (in seconds or ticks) before task starts + * @param runnable Task to add + * @param ticks Set to true to pass the delay as ticks instead of seconds + */ + public void addTask(int delay, @NotNull Runnable runnable, boolean ticks) { + addTask(delay, false, runnable, ticks); + } + /** * Add task to the scheduler. * * @param delay Delay (in seconds) before task starts - * @param repeat Delay (in seconds) before task repeats + * @param repeat Whether this task should repeat * @param runnable Task to add */ public void addTask(int delay, boolean repeat, @NotNull Runnable runnable) { - addTask(new Task(delay, repeat) { + addTask(delay, repeat, runnable, false); + } + + /** + * Add task to the scheduler. + * + * @param delay Delay (in seconds or ticks) before task starts + * @param repeat Whether this task should repeat + * @param runnable Task to add + * @param ticks Set to true to pass the delay as ticks instead of seconds + */ + public void addTask(int delay, boolean repeat, @NotNull Runnable runnable, boolean ticks) { + addTask(new Task(ticks ? delay : TickUtil.toTicks(delay), repeat) { @Override public void run() { runnable.run(); diff --git a/core/src/main/java/net/pl3x/map/core/scheduler/Task.java b/core/src/main/java/net/pl3x/map/core/scheduler/Task.java index b294e8464..b332496f4 100644 --- a/core/src/main/java/net/pl3x/map/core/scheduler/Task.java +++ b/core/src/main/java/net/pl3x/map/core/scheduler/Task.java @@ -33,7 +33,7 @@ public abstract class Task implements Runnable { /** * Creates a new schedulable task. * - * @param delay Delay (in seconds) before task starts + * @param delay Delay (in ticks) before task starts */ public Task(int delay) { this(delay, false); @@ -42,7 +42,7 @@ public Task(int delay) { /** * Creates a new schedulable task. * - * @param delay Delay (in seconds) before task starts + * @param delay Delay (in ticks) before task starts * @param repeat Whether this task should repeat */ public Task(int delay, boolean repeat) { diff --git a/core/src/main/java/net/pl3x/map/core/util/TickUtil.java b/core/src/main/java/net/pl3x/map/core/util/TickUtil.java new file mode 100644 index 000000000..3a07edd3d --- /dev/null +++ b/core/src/main/java/net/pl3x/map/core/util/TickUtil.java @@ -0,0 +1,38 @@ +/* + * MIT License + * + * Copyright (c) 2020-2023 William Blake Galbreath + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.pl3x.map.core.util; + +public class TickUtil { + public static double toSeconds(int ticks) { + return ticks * (1.0 / 20); + } + + public static int toTicks(int seconds) { + return seconds * 20; + } + + public static int toMilliseconds(int ticks) { + return ticks * 50; + } +} diff --git a/core/src/main/java/net/pl3x/map/core/world/World.java b/core/src/main/java/net/pl3x/map/core/world/World.java index 9fe36ddcc..d20f71943 100644 --- a/core/src/main/java/net/pl3x/map/core/world/World.java +++ b/core/src/main/java/net/pl3x/map/core/world/World.java @@ -41,10 +41,12 @@ import javax.imageio.ImageIO; import net.pl3x.map.core.Keyed; import net.pl3x.map.core.Pl3xMap; +import net.pl3x.map.core.configuration.Config; import net.pl3x.map.core.configuration.PlayersLayerConfig; import net.pl3x.map.core.configuration.SpawnLayerConfig; import net.pl3x.map.core.configuration.WorldBorderLayerConfig; import net.pl3x.map.core.configuration.WorldConfig; +import net.pl3x.map.core.httpd.LiveDataHandler; import net.pl3x.map.core.image.IconImage; import net.pl3x.map.core.log.Logger; import net.pl3x.map.core.markers.Point; @@ -58,6 +60,7 @@ import net.pl3x.map.core.registry.BiomeRegistry; import net.pl3x.map.core.registry.Registry; import net.pl3x.map.core.renderer.Renderer; +import net.pl3x.map.core.renderer.task.UpdateLiveData; import net.pl3x.map.core.renderer.task.UpdateMarkerData; import net.pl3x.map.core.util.FileUtil; import net.pl3x.map.core.util.Mathf; @@ -79,6 +82,8 @@ public abstract class World extends Keyed { private final Point spawn; private final Type type; + private final LiveDataHandler liveDataHandler; + private final BiomeManager biomeManager; private final BiomeRegistry biomeRegistry; private final Registry<@NotNull Layer> layerRegistry; @@ -87,6 +92,7 @@ public abstract class World extends Keyed { private final RegionModifiedState regionModifiedState; //private final RegionFileWatcher regionFileWatcher; private final UpdateMarkerData markerTask; + private final UpdateLiveData liveDataTask; private final Map<@NotNull String, Renderer.@NotNull Builder> renderers = new LinkedHashMap<>(); public World(@NotNull String name, long seed, @NotNull Point spawn, @NotNull Type type, @NotNull Path regionDirectory) { @@ -96,6 +102,8 @@ public World(@NotNull String name, long seed, @NotNull Point spawn, @NotNull Typ this.spawn = spawn; this.type = type; + this.liveDataHandler = new LiveDataHandler(); + String safeNameForDirectories = name.replace(":", "-"); this.regionDirectory = regionDirectory; @@ -122,6 +130,7 @@ public World(@NotNull String name, long seed, @NotNull Point spawn, @NotNull Typ this.regionModifiedState = new RegionModifiedState(this); //this.regionFileWatcher = new RegionFileWatcher(this); this.markerTask = new UpdateMarkerData(this); + this.liveDataTask = new UpdateLiveData(this, Config.LIVE_UPDATE_THREADS); } protected void init() { @@ -167,7 +176,10 @@ protected void init() { Pl3xMap.api().getRegionProcessor().addRegions(this, listRegions(false)); Logger.debug("Starting marker task"); - Pl3xMap.api().getScheduler().addTask(1, true, this.markerTask); + Pl3xMap.api().getScheduler().addTask(this.markerTask); + + Logger.debug("Starting live data task"); + Pl3xMap.api().getScheduler().addTask(this.liveDataTask); // load up custom markers Logger.debug("Loading custom markers for " + getName()); @@ -213,6 +225,10 @@ public void cleanup() { return this.markerTask; } + public @NotNull UpdateLiveData getLiveDataTask() { + return this.liveDataTask; + } + public @NotNull Map<@NotNull String, Renderer.@NotNull Builder> getRenderers() { return Collections.unmodifiableMap(this.renderers); } @@ -251,6 +267,10 @@ public int getSkylight() { return this.type; } + public LiveDataHandler getServerSentEventHandler() { + return liveDataHandler; + } + public @NotNull BiomeManager getBiomeManager() { return this.biomeManager; } diff --git a/fabric/src/main/java/net/pl3x/map/fabric/client/manager/TileManager.java b/fabric/src/main/java/net/pl3x/map/fabric/client/manager/TileManager.java index 45a9c626d..6e5a1c8cb 100644 --- a/fabric/src/main/java/net/pl3x/map/fabric/client/manager/TileManager.java +++ b/fabric/src/main/java/net/pl3x/map/fabric/client/manager/TileManager.java @@ -37,6 +37,7 @@ import javax.imageio.ImageIO; import net.pl3x.map.core.scheduler.Task; import net.pl3x.map.core.util.Mathf; +import net.pl3x.map.core.util.TickUtil; import net.pl3x.map.fabric.client.Pl3xMapFabricClient; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; @@ -64,7 +65,7 @@ public void initialize() { // update once next tick this.mod.getScheduler().addTask(0, this::update); // setup repeating task to update every 5 seconds - this.task = new Task(5, true) { + this.task = new Task(TickUtil.toTicks(5), true) { @Override public void run() { update(); diff --git a/fabric/src/main/java/net/pl3x/map/fabric/server/Pl3xMapFabricServer.java b/fabric/src/main/java/net/pl3x/map/fabric/server/Pl3xMapFabricServer.java index 393a56ab6..dc67b2d1c 100644 --- a/fabric/src/main/java/net/pl3x/map/fabric/server/Pl3xMapFabricServer.java +++ b/fabric/src/main/java/net/pl3x/map/fabric/server/Pl3xMapFabricServer.java @@ -76,7 +76,6 @@ public class Pl3xMapFabricServer extends Pl3xMap implements DedicatedServerModIn private FabricServerAudiences adventure; private boolean firstTick = true; - private int tick; private FabricNetwork network; @@ -97,10 +96,7 @@ public void onInitializeServer() { Pl3xMap.api().getEventRegistry().callEvent(new ServerLoadedEvent()); this.firstTick = false; } - if (this.tick++ >= 20) { - this.tick = 0; - getScheduler().tick(); - } + getScheduler().tick(); }); ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> { diff --git a/webmap/src/Pl3xMap.ts b/webmap/src/Pl3xMap.ts index 0a69dd996..76253c292 100644 --- a/webmap/src/Pl3xMap.ts +++ b/webmap/src/Pl3xMap.ts @@ -6,6 +6,7 @@ import {getJSON} from "./util/Util"; import SidebarControl from "./control/SidebarControl"; import Pl3xMapLeafletMap from "./map/Pl3xMapLeafletMap"; import "./scss/styles.scss"; +import {Player} from "./player/Player"; window.onload = function (): void { window.pl3xmap = new Pl3xMap(); @@ -23,15 +24,24 @@ export class Pl3xMap { private readonly _playerManager: PlayerManager; private readonly _worldManager: WorldManager; + private _eventSource?: EventSource; + private _langPalette: Map = new Map(); private _settings?: Settings; + private _timestamp: number = (new Date()).getTime(); private _timer: NodeJS.Timeout | undefined; constructor() { Pl3xMap._instance = this; this._map = new Pl3xMapLeafletMap(this); + + window.addEventListener('beforeunload', function () { + if (Pl3xMap.instance.eventSource != undefined) { + Pl3xMap.instance.eventSource.close(); + } + }); this._controlManager = new ControlManager(this); this._playerManager = new PlayerManager(this); @@ -50,6 +60,7 @@ export class Pl3xMap { }); this.controlManager.sidebarControl = new SidebarControl(this); const promise: Promise = this.worldManager.init(this._settings); + this._eventSource = this.initSSE(); this.update(); return promise; }); @@ -60,14 +71,33 @@ export class Pl3xMap { } private update(): void { - getJSON('tiles/settings.json').then((json): void => { + if (this._eventSource?.readyState === EventSource.OPEN && (new Date()).getTime() - this._timestamp < 1000) { + this._timer = setTimeout(() => this.update(), 1000); + return; + } + getJSON('tiles/settings.json').then( (json): void => { this._settings = json as Settings; - this.playerManager.update(this._settings); + + this.playerManager.update(this._settings.players); this._timer = setTimeout(() => this.update(), 1000); }); } + private initSSE(): EventSource { + const eventSource = new EventSource("sse"); + + eventSource.addEventListener("settings", (ev: Event) => { + this._timestamp = (new Date()).getTime(); + const messageEvent = (ev as MessageEvent); + const json: any = JSON.parse(messageEvent.data); + this._settings = json as Settings; + this.playerManager.update(this._settings.players); + }); + + return eventSource; + } + get map(): Pl3xMapLeafletMap { return this._map; } @@ -84,6 +114,10 @@ export class Pl3xMap { return this._worldManager; } + get eventSource(): EventSource | undefined { + return this._eventSource; + } + get langPalette(): Map { return this._langPalette; } @@ -91,4 +125,8 @@ export class Pl3xMap { get settings(): Settings | undefined { return this._settings; } + + get timestamp(): number { + return this._timestamp; + } } diff --git a/webmap/src/control/LinkControl.ts b/webmap/src/control/LinkControl.ts index dbed4f04e..1f912c142 100644 --- a/webmap/src/control/LinkControl.ts +++ b/webmap/src/control/LinkControl.ts @@ -5,11 +5,16 @@ import {createSVGIcon, toPoint} from "../util/Util"; import Pl3xMapLeafletMap from "../map/Pl3xMapLeafletMap"; import '../svg/link.svg'; import {World} from "../world/World"; +import {Player} from "../player/Player"; export class LinkControl extends ControlBox { private readonly _dom: HTMLAnchorElement; + private _disabled: boolean; private onEvent = (): void => { + if (this._disabled) { + return; + } this.update(); } @@ -17,6 +22,10 @@ export class LinkControl extends ControlBox { super(pl3xmap, position); this._dom = L.DomUtil.create('a', 'leaflet-control leaflet-control-button leaflet-control-link'); this._dom.appendChild(createSVGIcon('link')); + this._disabled = false; + addEventListener('followplayer', (e: CustomEvent): void => { + this._disabled = !(e.detail == undefined); // e.detail returns "null" even though it's being set to "undefined" + }); } onAdd(map: Pl3xMapLeafletMap): HTMLAnchorElement { diff --git a/webmap/src/layergroup/MarkerLayer.ts b/webmap/src/layergroup/MarkerLayer.ts index 40b2adcef..f5f82cc0c 100644 --- a/webmap/src/layergroup/MarkerLayer.ts +++ b/webmap/src/layergroup/MarkerLayer.ts @@ -47,6 +47,7 @@ export class MarkerLayer extends L.LayerGroup { private readonly _markers: Map = new Map(); + private _timestamp: number = (new Date()).getTime(); private _timer: NodeJS.Timeout | undefined; constructor(key: string, label: string, interval: number, showControls: boolean, defaultHidden: boolean, priority: number, zIndex: number, pane: string, css: string) { @@ -114,46 +115,68 @@ export class MarkerLayer extends L.LayerGroup { return this._css; } - update(world: World): void { + get timestamp(): number { + return this._timestamp; + } + + set timestamp(timestamp: number) { + this._timestamp = timestamp; + } + + update(world: World, updateOverride?: boolean): void { + if (updateOverride === undefined) { + const time = (new Date()).getTime() - this._timestamp; + // console.log(world.eventSource?.readyState); + // console.log(`${this._key}: world.eventSource?.readyState === EventSource.OPEN is ${world.eventSource?.readyState === EventSource.OPEN}`); + // console.log(`${this._key}: time (${time}) < 1000 is ${time < 1000}`); + if (world.eventSource?.readyState === EventSource.OPEN && time < 1000) { + // console.log(this._key + ": source is open and timestamp is " + time + " which is less than 1000"); + return; + } + } + getJSON(`tiles/${world.name}/markers/${this._key}.json`) - .then((json): void => { - //this.clearLayers(); // do not just clear markers, remove the ones that are missing - const toRemove: Set = new Set(this._markers.keys()); - - for (const index in Object.keys(json)) { - const existing: Marker | undefined = this._markers.get(json[index].data.key); - if (existing) { - // update - const data = json[index]; - const options: MarkerOptions | undefined = isset(data.options) ? new MarkerOptions(data.options) : undefined; - existing.update(data.data, options); - // do not remove this marker - toRemove.delete(existing.key); - } else { - // new marker - const marker: Marker | undefined = this.parseMarker(json[index]); - if (marker) { - this._markers.set(marker.key, marker); - marker.marker.addTo(this); - // inform the events - fireCustomEvent('markeradded', marker); - } - } + .then((json): void => this.updateMarkers(json, world)); + } + + updateMarkers(json: any, world: World): void { + // console.log(`updating ${this._key} with update interval of ${this._updateInterval}`); + //this.clearLayers(); // do not just clear markers, remove the ones that are missing + const toRemove: Set = new Set(this._markers.keys()); + + for (const index in Object.keys(json)) { + const existing: Marker | undefined = this._markers.get(json[index].data.key); + if (existing) { + // update + const data = json[index]; + const options: MarkerOptions | undefined = isset(data.options) ? new MarkerOptions(data.options) : undefined; + existing.update(data.data, options); + // do not remove this marker + toRemove.delete(existing.key); + } else { + // new marker + const marker: Marker | undefined = this.parseMarker(json[index]); + if (marker) { + this._markers.set(marker.key, marker); + marker.marker.addTo(this); + // inform the events + fireCustomEvent('markeradded', marker); } + } + } + + toRemove.forEach((key: string): void => { + // remove players not in updated settings file + const marker: Marker | undefined = this._markers.get(key); + if (marker) { + this._markers.delete(key); + marker.marker.remove(); + this.removeLayer(marker.marker); + fireCustomEvent('markerremoved', marker); + } + }); - toRemove.forEach((key: string): void => { - // remove players not in updated settings file - const marker: Marker | undefined = this._markers.get(key); - if (marker) { - this._markers.delete(key); - marker.marker.remove(); - this.removeLayer(marker.marker); - fireCustomEvent('markerremoved', marker); - } - }); - - this._timer = setTimeout(() => this.update(world), this._updateInterval); - }); + this._timer = setTimeout(() => this.update(world), this._updateInterval); } unload(): void { diff --git a/webmap/src/player/PlayerManager.ts b/webmap/src/player/PlayerManager.ts index 7e2ddfd77..773d0c4f0 100644 --- a/webmap/src/player/PlayerManager.ts +++ b/webmap/src/player/PlayerManager.ts @@ -1,6 +1,5 @@ import {Pl3xMap} from "../Pl3xMap"; import {Player} from "./Player"; -import {Settings} from "../settings/Settings"; import {fireCustomEvent, toCenteredLatLng} from "../util/Util"; import {WorldManager} from "../world/WorldManager"; import Pl3xMapLeafletMap from "../map/Pl3xMapLeafletMap"; @@ -17,10 +16,10 @@ export class PlayerManager { this._pl3xmap = pl3xmap; } - public update(settings: Settings): void { + public update(players: Player[]): void { const toRemove: Set = new Set(this._players.keys()); - settings.players.forEach((data: Player): void => { + players.forEach((data: Player): void => { const existing: Player | undefined = this._players.get(data.uuid); if (existing) { // update existing diff --git a/webmap/src/sidebar/PlayersTab.ts b/webmap/src/sidebar/PlayersTab.ts index 565be5f83..edb79fc3c 100644 --- a/webmap/src/sidebar/PlayersTab.ts +++ b/webmap/src/sidebar/PlayersTab.ts @@ -1,7 +1,7 @@ import * as L from "leaflet"; import {Pl3xMap} from "../Pl3xMap"; import {Player} from "../player/Player"; -import {createSVGIcon, handleKeyboardEvent, isset} from "../util/Util"; +import {createSVGIcon, handleKeyboardEvent} from "../util/Util"; import BaseTab from "./BaseTab"; import '../svg/players.svg'; import {Lang} from "../settings/Lang"; @@ -67,7 +67,7 @@ export default class PlayersTab extends BaseTab { private _update(): void { const settings: Settings | undefined = this._pl3xmap.settings; - const online: string = String(isset(settings?.players) ? Object.keys(settings!.players).length : '???'); + const online: string = String(this._pl3xmap.playerManager.players.size); const max: string = String(settings?.maxPlayers ?? '???'); const title: any = settings?.lang.players?.label diff --git a/webmap/src/world/World.ts b/webmap/src/world/World.ts index c429ec347..1a19dd3d3 100644 --- a/webmap/src/world/World.ts +++ b/webmap/src/world/World.ts @@ -25,6 +25,8 @@ export class World { private _loaded: boolean = false; + private _eventSource?: EventSource; + private _timer: NodeJS.Timeout | undefined; constructor(pl3xmap: Pl3xMap, worldManager: WorldManager, settings: WorldSettings) { @@ -32,6 +34,27 @@ export class World { this._settings = settings; } + private initSSE() { + this._eventSource = new EventSource("sse/" + this.settings.name); + console.debug("initializing " + this.settings.name + " sse"); + + this._eventSource.addEventListener("markers", (ev: Event) => { + const messageEvent = (ev as MessageEvent); + const json: any = JSON.parse(messageEvent.data); + const key: string = json.key; + const markers: any[] = json.markers; + console.debug(json); + + if (messageEvent.data.length === 0) return; + + this._markerLayers.forEach(layer => { + if (layer.key !== key) return; + layer.timestamp = (new Date()).getTime(); + layer.updateMarkers(markers, this); + }); + }); + } + public load(): Promise { if (this._loaded) { return Promise.resolve(this); @@ -71,6 +94,8 @@ export class World { public unload(): void { clearTimeout(this._timer); // unload and clear markers + this._eventSource?.close(); + console.debug("closing " + this.settings.name + " sse"); this._markerLayers.forEach((layer: MarkerLayer) => layer.unload()) this._markerLayers = []; // unload renderer layer @@ -80,12 +105,13 @@ export class World { } public loadMarkers(): void { + this.initSSE(); getJSON(`tiles/${this.name}/markers.json`) .then((json): void => { (json as MarkerLayer[]).forEach((layer: MarkerLayer): void => { const markerLayer: MarkerLayer = new MarkerLayer(layer.key, layer.label, layer.updateInterval, layer.showControls, layer.defaultHidden, layer.priority, layer.zIndex, layer.pane, layer.css); this._markerLayers.push(markerLayer); - markerLayer.update(this); + markerLayer.update(this, true); }); }); } @@ -209,6 +235,10 @@ export class World { return this._biomePalette; } + get eventSource(): EventSource | undefined { + return this._eventSource; + } + get background(): string { switch (this.type) { case "nether":