diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java index fc11e5e..4a835f2 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java @@ -26,8 +26,6 @@ import it.unimi.dsi.fastutil.objects.Object2LongArrayMap; import it.unimi.dsi.fastutil.objects.Object2LongMap; -import java.io.File; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.HashMap; import java.util.IdentityHashMap; @@ -65,7 +63,7 @@ public static void bootstrap() { KeyBindingHelper.registerKeyBinding(OPEN_GUI_KEY); modConfig = ModConfig.load(); - modConfig.saveNow(); // creates the default file if it doesn't exist yet + modConfig.save(); // creates the default file if it doesn't exist yet ClientTickEvents.START_CLIENT_TICK.register((minecraft) -> { final GameContext gameContext = GameContext.get().orElse(null); @@ -240,11 +238,4 @@ public static void debugLog(String msg) { logger.info(msg); } } - - public static File getConfigDirectory() { - final String mcRoot = Minecraft.getInstance().gameDirectory.getAbsolutePath(); - var dir = Paths.get(mcRoot, "config", "MapSync").toFile(); - dir.mkdirs(); - return dir; - } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/JsonConfig.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/JsonConfig.java index 7d12d2e..1269f8e 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/JsonConfig.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/JsonConfig.java @@ -1,81 +1,75 @@ package gjum.minecraft.mapsync.mod.config; -import static gjum.minecraft.mapsync.mod.MapSyncMod.logger; - import com.google.gson.Gson; import com.google.gson.GsonBuilder; import java.io.File; import java.io.FileNotFoundException; -import java.io.FileOutputStream; import java.io.FileReader; -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.util.Timer; -import java.util.TimerTask; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import net.fabricmc.loader.api.FabricLoader; import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -/** - * subclasses must have constructor without args, to create default config - */ -public class JsonConfig { +abstract class JsonConfig { + private static final Logger LOGGER = LoggerFactory.getLogger(JsonConfig.class); static final Gson GSON = new GsonBuilder() - .excludeFieldsWithoutExposeAnnotation() - .setPrettyPrinting() - .create(); - - private static final Timer timer = new Timer(); - private static long saveLaterTimeout = 300; - private long lastSaveTime = 0; + .excludeFieldsWithoutExposeAnnotation() + .setPrettyPrinting() + .create(); protected File configFile; - /** - * Doesn't save any newly created config; for that, call `saveNow()`. - */ - public static @NotNull T load(@NotNull File file, Class clazz) { - try (FileReader reader = new FileReader(file)) { - T config = GSON.fromJson(reader, clazz); - config.configFile = file; - logger.info("Loaded existing {}", file); + protected abstract void resetToDefaults(); + + /// Doesn't save any newly created config; for that, call `saveNow()`. + protected static @NotNull T load( + final @NotNull File configFile, + final @NotNull Class configClass + ) { + Objects.requireNonNull(configFile); + Objects.requireNonNull(configClass); + T config; + try (final var reader = new FileReader(configFile)) { + config = GSON.fromJson(reader, configClass); + config.configFile = configFile; + LOGGER.info("Loaded existing {}", configFile); return config; - } catch (FileNotFoundException ignored) { - } catch (IOException e) { - logger.error("Failed to load config file {}", file, e); + } + catch (final FileNotFoundException ignored) {} + catch (final Exception e) { + LOGGER.error("Failed to load config file {}", configFile, e); } try { - final T config = clazz.getConstructor().newInstance(); - config.configFile = file; - logger.info("Created default {}", file); + config = configClass.getConstructor().newInstance(); + config.configFile = configFile; + config.resetToDefaults(); + LOGGER.info("Created default {}", configFile); return config; - } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | - InvocationTargetException ex) { - throw new IllegalArgumentException(ex); } - } - - public void saveLater() { - final long originalSaveRequestTime = System.currentTimeMillis(); - timer.schedule(new TimerTask() { - @Override - public void run() { - if (lastSaveTime > originalSaveRequestTime) return; // already saved while waiting - saveNow(); - } - }, saveLaterTimeout); + catch (final ReflectiveOperationException e) { + throw new IllegalStateException(e); + } } synchronized - public void saveNow() { + public void save() { + LOGGER.info("Saving {} to {}", getClass().getSimpleName(), this.configFile); try { - lastSaveTime = System.currentTimeMillis(); - logger.info("Saving {} to {}", getClass().getSimpleName(), configFile); - configFile.getParentFile().mkdirs(); - String json = GSON.toJson(this); - FileOutputStream fos = new FileOutputStream(configFile); - fos.write(json.getBytes()); - fos.close(); - } catch (IOException e) { - logger.error("Failed to save config file {}", configFile, e); + Files.createDirectories(this.configFile.getParentFile().toPath()); + Files.write( + this.configFile.toPath(), + GSON.toJson(this).getBytes() + ); + } + catch (final Exception e) { + LOGGER.error("Failed to save config file {}", this.configFile, e); } } + + protected static @NotNull Path getConfigDir() { + return FabricLoader.getInstance().getConfigDir().resolve("MapSync"); + } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ModConfig.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ModConfig.java index 5614b23..463d638 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ModConfig.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ModConfig.java @@ -1,38 +1,45 @@ package gjum.minecraft.mapsync.mod.config; import com.google.gson.annotations.Expose; -import java.io.File; -import java.nio.file.Path; +import org.jetbrains.annotations.NotNull; -import gjum.minecraft.mapsync.mod.MapSyncMod; -import net.minecraft.client.Minecraft; - -public class ModConfig extends JsonConfig { +public final class ModConfig extends JsonConfig { @Expose private boolean showDebugLog = false; public boolean isShowDebugLog() { - return showDebugLog; + return this.showDebugLog; } - public void setShowDebugLog(boolean value) { - showDebugLog = value; - saveLater(); + public void setShowDebugLog( + final boolean value + ) { + this.showDebugLog = value; } @Expose private int catchupWatermark = 100; public int getCatchupWatermark() { - return catchupWatermark; + return Math.max(1, this.catchupWatermark); + } + + public void setCatchupWatermark( + final int value + ) { + this.catchupWatermark = value; } - public void setCatchupWatermark(int value) { - catchupWatermark = value; - saveLater(); + @Override + protected void resetToDefaults() { + this.showDebugLog = false; + this.catchupWatermark = 100; } - public static ModConfig load() { - return ModConfig.load(new File(MapSyncMod.getConfigDirectory(), "mod-config.json"), ModConfig.class); + public static @NotNull ModConfig load() { + return ModConfig.load( + getConfigDir().resolve("mod-config.json").toFile(), + ModConfig.class + ); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ServerConfig.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ServerConfig.java index 63598ae..5b4bb2c 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ServerConfig.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/ServerConfig.java @@ -1,77 +1,45 @@ package gjum.minecraft.mapsync.mod.config; -import com.google.gson.Gson; -import com.google.gson.JsonObject; import com.google.gson.annotations.Expose; import gjum.minecraft.mapsync.mod.data.GameAddress; -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.stream.Collectors; - -import gjum.minecraft.mapsync.mod.MapSyncMod; -import net.minecraft.client.Minecraft; +import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; -public class ServerConfig extends JsonConfig { - public GameAddress gameAddress; - +public final class ServerConfig extends JsonConfig { @Expose - private @NotNull List syncServerAddresses = new ArrayList<>(); - - public @NotNull List getSyncServerAddresses() { - return syncServerAddresses; + private ArrayList syncServerAddresses = new ArrayList<>(); + + public @NotNull List<@NotNull String> getSyncServerAddresses() { + return this.syncServerAddresses.stream() + .map(String::trim) + .filter(StringUtils::isNotEmpty) + .map((address) -> address.contains(":") ? address : (address + ":12312")) + .distinct() + .collect(Collectors.toCollection(ArrayList::new)); } - public void setSyncServerAddresses(@NotNull List addresses) { - syncServerAddresses = addresses.stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(address -> !address.isEmpty()) - .map(address -> address.contains(":") ? address : (address + ":12312")) - .collect(Collectors.toCollection(ArrayList::new)); - - saveLater(); + public void setSyncServerAddresses( + final @NotNull List syncAddresses + ) { + this.syncServerAddresses = new ArrayList<>(syncAddresses); } - public static ServerConfig load(GameAddress gameAddress) { - var dir = Path.of(MapSyncMod.getConfigDirectory().getAbsolutePath(), gameAddress.asFsName()).toFile(); - dir.mkdirs(); - var conf = load(new File(dir, "server-config.json"), ServerConfig.class); - conf.gameAddress = gameAddress; - - loadDefaults(conf); - - conf.syncServerAddresses = conf.syncServerAddresses.stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(address -> !address.isEmpty()) - .collect(Collectors.toCollection(ArrayList::new)); - - return conf; + @Override + public void resetToDefaults() { + this.setSyncServerAddresses(List.of( + "ws://localhost:12312" + )); } - private static void loadDefaults(ServerConfig conf) { - ServerConfig defaults; - try (var input = ServerConfig.class.getResourceAsStream("/default-config.json")) { - if (input == null) return; - String json = new String(input.readAllBytes(), StandardCharsets.UTF_8); - JsonObject root = new Gson().fromJson(json, JsonObject.class); - JsonObject servers = root.get("servers").getAsJsonObject(); - // TODO: Don't get, instead iterate through keys - JsonObject server = servers.get(conf.gameAddress.address()).getAsJsonObject(); - defaults = GSON.fromJson(server, ServerConfig.class); - } catch (IllegalStateException | NullPointerException ignored) { - return; - } catch (Throwable e) { - e.printStackTrace(); - return; - } - if (conf.syncServerAddresses.isEmpty() && defaults.syncServerAddresses != null) { - conf.setSyncServerAddresses(defaults.syncServerAddresses); - } + public static @NotNull ServerConfig load( + final @NotNull GameAddress gameAddress + ) { + return load( + getConfigDir().resolve("%s.json".formatted(gameAddress.asFsName())).toFile(), + ServerConfig.class + ); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/gui/SyncConnectionsGui.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/gui/SyncConnectionsGui.java index fca4acd..2a6fa53 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/gui/SyncConnectionsGui.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/gui/SyncConnectionsGui.java @@ -3,10 +3,9 @@ import gjum.minecraft.mapsync.mod.sync.DimensionState; import gjum.minecraft.mapsync.mod.net.SyncClient; import gjum.minecraft.mapsync.mod.sync.GameContext; -import java.util.List; +import java.util.Arrays; import java.util.Objects; import java.util.Set; -import java.util.stream.Stream; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; @@ -64,12 +63,13 @@ protected void init() { .builder( Component.literal("Connect"), (button) -> { - final List syncAddresses = Stream.of(StringUtils.split(this.addressFieldValue, ',')) - .filter(StringUtils::isNotBlank) - .distinct() - .toList(); - this.gameContext.getGameConfig().setSyncServerAddresses(syncAddresses); - this.gameContext.getSyncConnections().setAll(Set.copyOf(syncAddresses)); + this.gameContext.getGameConfig().setSyncServerAddresses(Arrays.asList( + StringUtils.split(this.addressFieldValue, ',') + )); + this.gameContext.getGameConfig().save(); + this.gameContext.getSyncConnections().setAll(Set.copyOf( + this.gameContext.getGameConfig().getSyncServerAddresses() + )); } ) .pos(offsetRight - 100, this.offsetTop + 40) @@ -174,6 +174,7 @@ public boolean isPauseScreen() { @Override public void onClose() { + this.gameContext.getGameConfig().save(); this.minecraft.setScreen(this.parentScreen); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionChunkMeta.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionChunkMeta.java index a100683..0a236fe 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionChunkMeta.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionChunkMeta.java @@ -1,18 +1,25 @@ package gjum.minecraft.mapsync.mod.sync; -import gjum.minecraft.mapsync.mod.MapSyncMod; import gjum.minecraft.mapsync.mod.data.GameAddress; import gjum.minecraft.mapsync.mod.data.RegionPos; +import gjum.minecraft.mapsync.mod.utils.Assertions; +import gjum.minecraft.mapsync.mod.utils.MagicValues; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import java.nio.file.attribute.FileTime; import java.util.Arrays; -import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.resources.Identifier; import net.minecraft.world.level.ChunkPos; +import org.apache.commons.io.FileUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Stores each chunk's timestamp of when it was received from mc. @@ -20,23 +27,25 @@ * Each region's LastModifiedTime is set to the oldest contained chunk (or 0 if any chunks are absent), to easily find regions to request from the sync server. */ public class DimensionChunkMeta { - public final GameAddress gameAddress; - public final String dimensionName; - private final String dimensionDirPath; + private static final Logger LOGGER = LoggerFactory.getLogger(DimensionChunkMeta.class); + public static final long NULLISH_TIMESTAMP = Long.MIN_VALUE; - private final HashMap regionsTimestamps = new HashMap<>(); + public final GameAddress gameAddress; + private final Path dimensionDirPath; + private final Map regionsTimestamps; - DimensionChunkMeta(GameAddress gameAddress, String dimensionName) { + DimensionChunkMeta( + final @NotNull GameAddress gameAddress, + final @NotNull Identifier dimension + ) { this.gameAddress = gameAddress; - this.dimensionName = dimensionName; - var dir = Path.of(MapSyncMod.getConfigDirectory().getAbsolutePath(), "cache", - gameAddress.asFsName(), dimensionName.replaceAll(":", "~")); - dir.toFile().mkdirs(); - this.dimensionDirPath = dir.toAbsolutePath().toString(); - } - - private Path getRegionFilePath(RegionPos regionPos) { - return Path.of(dimensionDirPath, "r%d,%d.chunkmeta".formatted(regionPos.x(), regionPos.z())); + this.dimensionDirPath = FabricLoader.getInstance() + .getGameDir() + .resolve("data") + .resolve("MapSync") + .resolve(gameAddress.asFsName()) + .resolve(dimension.toString().replace(":", "~")); + this.regionsTimestamps = new ConcurrentHashMap<>(); } public synchronized long getOldestChunkTsInRegion(RegionPos regionPos) { @@ -60,48 +69,55 @@ public synchronized void setTimestamp(ChunkPos chunkPos, long timestamp) { } // Only call this to clear memory and file-cache - public synchronized void PurgeRegionTimeStamps() { - regionsTimestamps.clear(); + public synchronized void purgeRegionTimestamps() { + this.regionsTimestamps.clear(); try { - Path dir = Path.of(dimensionDirPath); - if (Files.exists(dir)) { - Files.walk(dir) - .sorted((a, b) -> b.compareTo(a)) // delete children first - .forEach(path -> { - try { Files.delete(path); } - catch (IOException e) { e.printStackTrace(); } - }); - } - Files.createDirectories(dir); - } catch (IOException e) { - e.printStackTrace(); + FileUtils.deleteDirectory(this.dimensionDirPath.toFile()); + } + catch (final IOException e) { + LOGGER.warn("Failed to purge region timestamps!", e); } } - private long[] readRegionTimestampsFile(RegionPos regionPos) { - long[] longs = new long[RegionPos.CHUNKS_IN_REGION]; + private long @NotNull [] readRegionTimestampsFile( + final @NotNull RegionPos regionPos + ) { + final var timestamps = new long[RegionPos.CHUNKS_IN_REGION]; + Arrays.fill(timestamps, NULLISH_TIMESTAMP); try { - final byte[] byteArray = Files.readAllBytes(getRegionFilePath(regionPos)); - ByteBuffer.wrap(byteArray).asLongBuffer().get(longs); - } catch (FileNotFoundException | NoSuchFileException ignored) { - } catch (IOException e) { - e.printStackTrace(); + final byte[] bytes = Files.readAllBytes(this.dimensionDirPath.resolve(getRegionFileName(regionPos))); + Assertions.assertLength(bytes.length, Long.BYTES * timestamps.length); + ByteBuffer.wrap(bytes).asLongBuffer().get(timestamps); } - return longs; + catch (final FileNotFoundException | NoSuchFileException ignored) {} + catch (final Exception e) { + LOGGER.warn("Failed to read region timestamps file for {}", regionPos, e); + } + return timestamps; } - private synchronized void writeRegionTimestampsFile(RegionPos regionPos, long[] chunkTimestamps) { + private synchronized void writeRegionTimestampsFile( + final @NotNull RegionPos regionPos, + final long @NotNull [] timestamps + ) { + Assertions.assertLength(timestamps.length, MagicValues.REGION_GRID); + final var bytes = new byte[Long.BYTES * timestamps.length]; + ByteBuffer.wrap(bytes).asLongBuffer().put(timestamps); try { - final var buffer = ByteBuffer.allocate(8 * RegionPos.CHUNKS_IN_REGION); - buffer.asLongBuffer().put(chunkTimestamps); - buffer.flip(); - Path path = getRegionFilePath(regionPos); - Files.write(path, buffer.array()); - // include absent chunks (ts=0) because sync server may have a chunk there (i.e. newer than 0) - long oldestChunkTs = Arrays.stream(chunkTimestamps).min().orElseThrow(); - Files.setLastModifiedTime(path, FileTime.fromMillis(oldestChunkTs)); - } catch (IOException e) { - e.printStackTrace(); + Files.createDirectories(this.dimensionDirPath); + Files.write(this.dimensionDirPath.resolve(getRegionFileName(regionPos)), bytes); + } + catch (final IOException e) { + LOGGER.warn("Failed to write region timestamps file for {}", regionPos, e); } } + + private @NotNull String getRegionFileName( + final @NotNull RegionPos regionPos + ) { + return "r%d,%d.chunkmeta".formatted( + regionPos.x(), + regionPos.z() + ); + } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionState.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionState.java index f4d0b8d..5af5739 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionState.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionState.java @@ -29,8 +29,7 @@ public class DimensionState { DimensionState(GameAddress gameAddress, ResourceKey dimension) { this.dimension = dimension; - String dimensionName = dimension.identifier().toString(); - chunkMeta = new DimensionChunkMeta(gameAddress, dimensionName); + chunkMeta = new DimensionChunkMeta(gameAddress, dimension.identifier()); renderQueue = new RenderQueue(this); catchup = new CatchupLogic(this); } @@ -53,7 +52,7 @@ public void setChunkTimestamp(ChunkPos chunkPos, long timestamp) { chunkMeta.setTimestamp(chunkPos, timestamp); } - public void PurgeRegionTimeStamps() { chunkMeta.PurgeRegionTimeStamps(); } + public void PurgeRegionTimeStamps() { chunkMeta.purgeRegionTimestamps(); } public int getNumChunksReceived() { return numChunksReceived; diff --git a/mapsync-mod/src/main/resources/default-config.json b/mapsync-mod/src/main/resources/default-config.json deleted file mode 100644 index 054e88e..0000000 --- a/mapsync-mod/src/main/resources/default-config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "servers": { - "localhost:25565": { - "syncServerAddresses": [ - "ws://localhost:12312" - ] - } - } -}