diff --git a/README.md b/README.md index 62fb1d0..bbb80ab 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ ## 支持的版本 -- Minecraft:1.6.1~1.21.11、26.1~26.1.2 都支持 +- Minecraft:1.6.1~1.21.11、26.1~26.3 都支持 - Mod加载器:MinecraftForge、NeoForge、Fabric、Quilt 都支持 -- Java:8~25 都支持 +- Java:8~25 都支持 仅仅需要在mods文件夹中放置本Mod的jar文件即可,Mod本身与各主流Minecraft版本、Mod Loader、Java版本均兼容,Mod本身不需要进行任何版本隔离。 @@ -36,6 +36,14 @@ - 合并转换:会合并加转换最近版本的一些资源包,尽可能做最大化的支持 - 特别说明:1.13开始将语言文件变化为json格式,所以不能将1.12.2的资源包用于1.13以上,反之同理 +## 配置 + +首次启动会在 `config/i18nupdatemod.json` 生成带说明的模板。 + +- `cacheTtlHours`:两次联网检查之间的最短间隔,默认 `24` 小时,`0` 表示每次启动都检查。间隔内本地没有变化就完全不联网、不重新组包;新增/删除模组、模组文件更新或修改 `excludeMods` 会立即触发检查。 +- `excludeMods`:不参与汉化的模组命名空间(例如 `jei`、`create`),跳过下载、也不会进入汉化包;已有缓存不动。 +- `onlyWhenChineseSystem`:为 `true` 时只在系统语言为中文时才启动;非中文系统下本Mod完全不动作(不联网、不下载、不写任何文件)。默认 `false`,整合包作者可设为 `true` 后随包分发。判定读的是操作系统区域设置,不是游戏内语言。 + ## 开发环境 构建需要 JDK 21(用于编译新版 NeoForge 的服务接口);产物仍使用 Java 8 字节码,旧版游戏的 Java 运行要求不变。 diff --git a/build.gradle.kts b/build.gradle.kts index 1cb1a4a..c1f50e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -57,15 +57,17 @@ mapOf( val bmclBaseUrl = if (variant == "Release") { "https://bmclapi2.bangbang93.com/mirrors/i18n-update-mod/" } else "" + val githubBaseUrl = "https://github.com/CFPAOrg/Minecraft-Mod-Language-Package/releases/download/autobuild/" val configFile = layout.buildDirectory.file("generated/buildConfig/$variant/i18n-build.properties") val generateConfig = tasks.register("generate${variant}Config") { inputs.property("assetBaseUrl", baseUrl) inputs.property("bmclBaseUrl", bmclBaseUrl) + inputs.property("githubBaseUrl", githubBaseUrl) outputs.file(configFile) doLast { configFile.get().asFile.apply { parentFile.mkdirs() - writeText("assetBaseUrl=$baseUrl\nbmclBaseUrl=$bmclBaseUrl\n") + writeText("assetBaseUrl=$baseUrl\nbmclBaseUrl=$bmclBaseUrl\ngithubBaseUrl=$githubBaseUrl\n") } } } diff --git a/gradle.properties b/gradle.properties index 435a08c..94167e1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ version=3.7.1 -minecraft=1.6.1,1.6.2,1.6.4,1.7.2,1.7.10,1.8,1.8.8,1.8.9,1.9,1.9.4,1.10,1.10.2,1.11,1.11.2,1.12,1.12.1,1.12.2,1.13.2,1.14,1.14.1,1.14.2,1.14.3,1.14.4,1.15,1.15.1,1.15.2,1.16,1.16.1,1.16.2,1.16.3,1.16.4,1.16.5,1.17,1.17.1,1.18,1.18.1,1.18.2,1.19,1.19.1,1.19.2,1.19.3,1.19.4,1.20,1.20.1,1.20.2,1.20.3,1.20.4,1.20.5,1.20.6,1.21,1.21.1,1.21.2,1.21.3,1.21.4,1.21.5,1.21.6,1.21.7,1.21.8,1.21.9,1.21.10,1.21.11,26.1,26.1.1,26.1.2 +minecraft=1.6.1,1.6.2,1.6.4,1.7.2,1.7.10,1.8,1.8.8,1.8.9,1.9,1.9.4,1.10,1.10.2,1.11,1.11.2,1.12,1.12.1,1.12.2,1.13.2,1.14,1.14.1,1.14.2,1.14.3,1.14.4,1.15,1.15.1,1.15.2,1.16,1.16.1,1.16.2,1.16.3,1.16.4,1.16.5,1.17,1.17.1,1.18,1.18.1,1.18.2,1.19,1.19.1,1.19.2,1.19.3,1.19.4,1.20,1.20.1,1.20.2,1.20.3,1.20.4,1.20.5,1.20.6,1.21,1.21.1,1.21.2,1.21.3,1.21.4,1.21.5,1.21.6,1.21.7,1.21.8,1.21.9,1.21.10,1.21.11,26.1,26.1.1,26.1.2,26.2,26.3 curseforge=NeoForge,Forge,Fabric,Quilt,Client,Java 8,Java 9,Java 10,Java 11,Java 12,Java 13,Java 14,Java 15,Java 16,Java 17,Java 18 \ No newline at end of file diff --git a/src/main/java/i18nupdatemod/I18nUpdateMod.java b/src/main/java/i18nupdatemod/I18nUpdateMod.java index 0e76a42..77e179e 100644 --- a/src/main/java/i18nupdatemod/I18nUpdateMod.java +++ b/src/main/java/i18nupdatemod/I18nUpdateMod.java @@ -3,6 +3,7 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import i18nupdatemod.core.GameConfig; +import i18nupdatemod.core.I18nUserConfig; import i18nupdatemod.core.ResourcePackUpdater; import i18nupdatemod.core.RuntimePackActivation; import i18nupdatemod.entity.ModTranslation; @@ -15,6 +16,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.stream.Stream; @@ -24,6 +26,22 @@ public class I18nUpdateMod { public static final Gson GSON = new Gson(); + /** Runs before any file-backed logging: loads the user config, applies the system-language gate, opens the log file. Returns false to stay completely idle. */ + public static boolean prepare(Path minecraftPath) { + I18nUserConfig.load(minecraftPath); + boolean idle = I18nUserConfig.onlyWhenChineseSystem() && !isChineseSystem(); + if (!idle) { + Log.setMinecraftLogFile(minecraftPath); + } + I18nUserConfig.flushLogMessages(); + if (idle) { + RuntimePackActivation.disable(); + Log.info("System language is not Chinese; I18nUpdateMod stays idle."); + return false; + } + return true; + } + public static void init(Path minecraftPath, String minecraftVersion, String loader, @NotNull List mods) { try (InputStream is = I18nUpdateMod.class.getResourceAsStream("/i18nMetaData.json")) { MOD_VERSION = GSON.fromJson(new InputStreamReader(is), JsonObject.class).get("version").getAsString(); @@ -66,11 +84,9 @@ public static void init(Path minecraftPath, String minecraftVersion, String load } //Apply resource pack - GameConfig config = new GameConfig(minecraftPath.resolve("options.txt")); - config.addResourcePack("Minecraft-Mod-Language-Modpack", + GameConfig.addResourcePack(minecraftPath.resolve("options.txt"), "Minecraft-Mod-Language-Modpack", (Version.from(minecraftVersion).compareTo(Version.from("1.13")) < 0 ? "" : "file/") + convertedPack.getFileName().toString()); - config.writeToFile(); } catch (Exception e) { Log.warning(String.format("Failed to update resource pack: %s", e)); // e.printStackTrace(); @@ -78,6 +94,10 @@ public static void init(Path minecraftPath, String minecraftVersion, String load } + private static boolean isChineseSystem() { + return "zh".equalsIgnoreCase(Locale.getDefault().getLanguage()); + } + public static String getLocalStoragePos(Path minecraftPath) { Path userHome = Paths.get(System.getProperty("user.home")); Path oldPath = userHome.resolve("." + MOD_ID); diff --git a/src/main/java/i18nupdatemod/core/GameConfig.java b/src/main/java/i18nupdatemod/core/GameConfig.java index 37ea9c1..2bed40f 100644 --- a/src/main/java/i18nupdatemod/core/GameConfig.java +++ b/src/main/java/i18nupdatemod/core/GameConfig.java @@ -3,7 +3,6 @@ import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import i18nupdatemod.util.Log; -import org.apache.commons.io.FileUtils; import java.io.ByteArrayOutputStream; import java.lang.reflect.Type; @@ -13,12 +12,10 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; public class GameConfig { private static final Gson GSON = new Gson(); @@ -26,28 +23,24 @@ public class GameConfig { }.getType(); private static final byte[] RESOURCE_PACKS_KEY = "resourcePacks".getBytes(StandardCharsets.US_ASCII); - protected Map configs = new LinkedHashMap<>(); - private final Path configFile; - public GameConfig(Path configFile) throws Exception { - this.configFile = configFile; - if (!Files.exists(configFile)) { - return; - } - this.configs = FileUtils.readLines(configFile.toFile(), StandardCharsets.UTF_8).stream() - .map(it -> it.split(":", 2)) - .filter(it -> it.length == 2) - .collect(Collectors.toMap(it -> it[0], it -> it[1], (a, b) -> a, LinkedHashMap::new)); + private GameConfig() { } - public void writeToFile() throws Exception { - FileUtils.writeLines(configFile.toFile(), "UTF-8", configs.entrySet().stream() - .map(it -> it.getKey() + ":" + it.getValue()).collect(Collectors.toList())); + public static void addResourcePack(Path configFile, String baseName, String resourcePack) + throws Exception { + List resourcePacks = new ArrayList<>(readResourcePacks(configFile)); + if (resourcePacks.contains(resourcePack)) { + return; + } + // 换版本时清掉旧的同类包 + resourcePacks.removeIf(it -> it.contains(baseName)); + resourcePacks.add(resourcePack); + updateResourcePacks(configFile, resourcePacks); + Log.info("Resource Packs: %s", resourcePacks); } - /** - * 避免NeoForge修改资源包顺序导致的按键配置丢失问题。 - */ + /** 避免NeoForge修改资源包顺序导致的按键配置丢失问题。 */ static void updateResourcePacks(Path configFile, List resourcePacks) throws Exception { Path absolute = configFile.toAbsolutePath().normalize(); @@ -115,10 +108,8 @@ static List readResourcePacks(Path configFile) throws Exception { } List selected = GSON.fromJson( new String(bytes, valueStart, valueEnd - valueStart, StandardCharsets.UTF_8), STRING_LIST_TYPE); - if (selected == null) { - throw new IllegalStateException("resourcePacks must be a JSON array"); - } - return selected; + // 空值(如 `resourcePacks:` 后面什么都没有)Gson 返回 null,等同于没有任何资源包 + return selected == null ? Collections.emptyList() : selected; } private static byte[] replaceResourcePacksLines(byte[] original, byte[] encodedLine) { @@ -206,20 +197,5 @@ private static boolean containsCrlf(byte[] bytes) { return false; } - public void addResourcePack(String baseName, String resourcePack) { - List resourcePacks = GSON.fromJson( - configs.computeIfAbsent("resourcePacks", it -> "[]"), STRING_LIST_TYPE); - //If resource packs already contains target resource pack, nothing to do - if (resourcePacks.contains(resourcePack)) { - return; - } - //Remove other Minecraft Mod Language Pack - resourcePacks = resourcePacks.stream().filter(it -> !it.contains(baseName)).collect(Collectors.toList()); - resourcePacks.add(resourcePack); - configs.put("resourcePacks", GSON.toJson(resourcePacks)); - Log.info(String.format("Resource Packs: %s", configs.get("resourcePacks"))); -// configs.put("lang", "zh_cn"); - } - } diff --git a/src/main/java/i18nupdatemod/core/I18nUserConfig.java b/src/main/java/i18nupdatemod/core/I18nUserConfig.java new file mode 100644 index 0000000..a06fa49 --- /dev/null +++ b/src/main/java/i18nupdatemod/core/I18nUserConfig.java @@ -0,0 +1,200 @@ +package i18nupdatemod.core; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.stream.JsonReader; +import i18nupdatemod.util.Log; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** User-editable options read once per launch from {@code config/i18nupdatemod.json}; never throws, always falls back to defaults. */ +public class I18nUserConfig { + private static final Gson GSON = new Gson(); + private static final String FILE_NAME = "i18nupdatemod.json"; + private static final long DEFAULT_CACHE_TTL_HOURS = 24; + private static final long MAX_CACHE_TTL_HOURS = TimeUnit.DAYS.toHours(365); + private static final String TEMPLATE = "{\n" + + " \"_comment_cacheTtlHours\": \"更新检查间隔(小时):上次联网检查后多久之内不再联网,0 表示每次启动都检查。本地模组或 excludeMods 有变化会立即重新检查。默认 24。\",\n" + + " \"cacheTtlHours\": 24,\n" + + " \"_comment_excludeMods\": \"不参与汉化的模组命名空间(例如 jei、create):跳过下载,也不会进入汉化包;已有缓存不动。\",\n" + + " \"excludeMods\": [],\n" + + " \"_comment_onlyWhenChineseSystem\": \"为 true 时,只在系统语言(操作系统区域设置,不是游戏内语言)为中文时才启动:非中文系统下模组完全不动作,不联网、不下载、不写任何文件。整合包作者可设为 true 后随包分发。默认 false。\",\n" + + " \"onlyWhenChineseSystem\": false\n" + + "}\n"; + + private static volatile long cacheTtlMillis = TimeUnit.HOURS.toMillis(DEFAULT_CACHE_TTL_HOURS); + private static volatile List excludedMods = Collections.emptyList(); + private static volatile boolean onlyWhenChineseSystem; + private static final List pendingDebug = new ArrayList<>(); + private static final List pendingWarnings = new ArrayList<>(); + + public static void load(Path gameDirectory) { + Path file = gameDirectory.resolve("config").resolve(FILE_NAME); + if (!Files.isRegularFile(file)) { + writeTemplate(file); + return; + } + JsonObject data = read(file); + if (data == null) { + return; + } + // 逐字段读取:某个字段写错不能让 onlyWhenChineseSystem 跟着回默认,否则门禁会静默失效 + cacheTtlMillis = TimeUnit.HOURS.toMillis(validTtlHours(longValue(data, "cacheTtlHours", file), file)); + excludedMods = normalize(stringList(data, "excludeMods", file), file); + onlyWhenChineseSystem = booleanValue(data, "onlyWhenChineseSystem", file); + debug("User config: cacheTtlHours=%s excludeMods=%s onlyWhenChineseSystem=%s", + TimeUnit.MILLISECONDS.toHours(cacheTtlMillis), excludedMods, onlyWhenChineseSystem); + } + + /** 读配置早于日志文件打开,攒下的消息由 prepare() 在开完日志后输出。 */ + public static void flushLogMessages() { + List debugs; + List warnings; + synchronized (pendingDebug) { + debugs = new ArrayList<>(pendingDebug); + warnings = new ArrayList<>(pendingWarnings); + pendingDebug.clear(); + pendingWarnings.clear(); + } + for (String line : debugs) { + Log.debug(line); + } + for (String line : warnings) { + Log.warning(line); + } + } + + public static long cacheTtlMillis() { + return cacheTtlMillis; + } + + public static List excludedMods() { + return excludedMods; + } + + public static boolean isExcluded(String namespace) { + return namespace != null && excludedMods.contains(namespace.toLowerCase(Locale.ROOT)); + } + + public static boolean onlyWhenChineseSystem() { + return onlyWhenChineseSystem; + } + + private static long validTtlHours(long hours, Path file) { + if (hours < 0 || hours > MAX_CACHE_TTL_HOURS) { + warning("cacheTtlHours %s out of range in %s, using %s", hours, file, DEFAULT_CACHE_TTL_HOURS); + return DEFAULT_CACHE_TTL_HOURS; + } + return hours; + } + + private static List normalize(List names, Path file) { + if (names == null || names.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(names.size()); + for (String name : names) { + if (name == null || name.trim().isEmpty()) { + warning("Ignoring empty excludeMods entry in %s", file); + continue; + } + result.add(name.trim().toLowerCase(Locale.ROOT)); + } + return Collections.unmodifiableList(result); + } + + private static void writeTemplate(Path file) { + try { + Files.createDirectories(file.getParent()); + Files.write(file, TEMPLATE.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE_NEW); + debug("Created default configuration: %s", file); + } catch (FileAlreadyExistsException e) { + Log.debug("Configuration already created by another thread: %s", file); + } catch (IOException e) { + Log.warning("Failed to create %s: %s", file, e); + } + } + + private static JsonObject read(Path file) { + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + JsonReader json = new JsonReader(reader); + // 宽松模式:容忍手写配置常见的不带引号键名 + json.setLenient(true); + JsonObject data = GSON.fromJson(json, JsonObject.class); + if (data == null) { + throw new IOException("configuration is empty"); + } + return data; + } catch (Exception e) { + warning("Failed to read %s, keeping defaults: %s", file, e); + return null; + } + } + + private static long longValue(JsonObject data, String key, Path file) { + JsonElement value = data.get(key); + if (value == null || value.isJsonNull()) { + return DEFAULT_CACHE_TTL_HOURS; + } + try { + return value.getAsLong(); + } catch (RuntimeException e) { + warning("%s in %s must be a whole number, using %s", key, file, DEFAULT_CACHE_TTL_HOURS); + return DEFAULT_CACHE_TTL_HOURS; + } + } + + private static List stringList(JsonObject data, String key, Path file) { + JsonElement value = data.get(key); + if (value == null || value.isJsonNull()) { + return Collections.emptyList(); + } + try { + List names = new ArrayList<>(); + for (JsonElement name : value.getAsJsonArray()) { + names.add(name == null || name.isJsonNull() ? null : name.getAsString()); + } + return names; + } catch (RuntimeException e) { + warning("%s in %s must be an array of strings, ignoring it", key, file); + return Collections.emptyList(); + } + } + + private static boolean booleanValue(JsonObject data, String key, Path file) { + JsonElement value = data.get(key); + if (value == null || value.isJsonNull()) { + return false; + } + try { + return value.getAsBoolean(); + } catch (RuntimeException e) { + warning("%s in %s must be true or false, keeping false", key, file); + return false; + } + } + + private static void debug(String format, Object... args) { + synchronized (pendingDebug) { + pendingDebug.add(String.format(format, args)); + } + } + + private static void warning(String format, Object... args) { + synchronized (pendingDebug) { + pendingWarnings.add(String.format(format, args)); + } + } +} diff --git a/src/main/java/i18nupdatemod/core/ResourcePackUpdater.java b/src/main/java/i18nupdatemod/core/ResourcePackUpdater.java index e2edc42..eab6c99 100644 --- a/src/main/java/i18nupdatemod/core/ResourcePackUpdater.java +++ b/src/main/java/i18nupdatemod/core/ResourcePackUpdater.java @@ -1,21 +1,143 @@ package i18nupdatemod.core; +import com.google.gson.Gson; import i18nupdatemod.core.v1.ResourcePackV1; import i18nupdatemod.core.v2.ResourcePackV2; +import i18nupdatemod.entity.GameAssetDetail; import i18nupdatemod.entity.ModTranslation; +import i18nupdatemod.util.DigestUtil; import i18nupdatemod.util.Log; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.concurrent.TimeUnit; public class ResourcePackUpdater { + private static final Gson GSON = new Gson(); + // 资源文件的 mtime 是内容更新时间,不是检查时间,所以检查时间单独存 + private static final String CHECK_STAMP = "check-stamp.json"; + public static Path update(String minecraftVersion, String loader, List mods, Path resourcePackDirectory, Path cacheRoot) throws Exception { + GameAssetDetail plan = I18nConfig.getAssetDetail(minecraftVersion); + Path convertedPack = resourcePackDirectory.resolve(plan.convertedFileName); + long ttl = I18nUserConfig.cacheTtlMillis(); + String fingerprint = fingerprint(minecraftVersion, mods); + if (checkedRecently(cacheRoot, convertedPack, ttl, fingerprint)) { + Log.debug("Skipping the update check: unchanged inputs, last check within %s hours.", + TimeUnit.MILLISECONDS.toHours(ttl)); + return convertedPack; + } try { - return ResourcePackV2.update(minecraftVersion, mods, resourcePackDirectory, cacheRoot); + Path updated = ResourcePackV2.update(minecraftVersion, mods, resourcePackDirectory, cacheRoot); + markChecked(cacheRoot, fingerprint); + return updated; } catch (Exception e) { Log.warning("V2 resource pipeline failed; falling back to V1: %s", e); } - return ResourcePackV1.update(minecraftVersion, loader, mods, resourcePackDirectory, cacheRoot); + Path updated = ResourcePackV1.update(minecraftVersion, loader, mods, resourcePackDirectory, cacheRoot); + markChecked(cacheRoot, fingerprint); + return updated; + } + + static boolean checkedRecently(Path cacheRoot, Path convertedPack, long ttlMillis, String fingerprint) { + if (ttlMillis <= 0L) { + return false; + } + try { + if (!Files.isRegularFile(convertedPack) || Files.size(convertedPack) <= 0L) { + return false; + } + Path stampFile = cacheRoot.resolve(CHECK_STAMP); + if (!Files.isRegularFile(stampFile)) { + return false; + } + CheckStamp stamp = GSON.fromJson( + new String(Files.readAllBytes(stampFile), StandardCharsets.UTF_8), CheckStamp.class); + if (stamp == null) { + return false; + } + long age = System.currentTimeMillis() - stamp.checkedAt; + return fingerprint.equals(stamp.fingerprint) && age >= 0L && age < ttlMillis; + } catch (Exception e) { + return false; + } + } + + static void markChecked(Path cacheRoot, String fingerprint) { + try { + Files.createDirectories(cacheRoot); + Path stampFile = cacheRoot.resolve(CHECK_STAMP); + Path temporary = Files.createTempFile(cacheRoot, ".check-stamp-", ".tmp"); + try { + CheckStamp stamp = new CheckStamp(); + stamp.checkedAt = System.currentTimeMillis(); + stamp.fingerprint = fingerprint; + Files.write(temporary, GSON.toJson(stamp).getBytes(StandardCharsets.UTF_8)); + try { + Files.move(temporary, stampFile, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, stampFile, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } catch (IOException e) { + Log.warning("Cannot record the update check in %s: %s", cacheRoot, e); + } + } + + // 指纹覆盖所有影响选包的本地输入:游戏版本、模组文件、排除项 + static String fingerprint(String minecraftVersion, List mods) + throws NoSuchAlgorithmException { + List excluded = new ArrayList<>(I18nUserConfig.excludedMods()); + Collections.sort(excluded); + List lines = new ArrayList<>(); + lines.add("version=" + minecraftVersion); + lines.add("excluded=" + excluded); + if (mods != null) { + for (ModTranslation mod : mods) { + if (mod == null) { + continue; + } + lines.add(mod.namespace + "|" + mod.source.getFileName() + + "|" + sizeOf(mod.source) + "|" + modifiedOf(mod.source)); + } + } + Collections.sort(lines); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return DigestUtil.hexString( + digest.digest(String.join("\n", lines).getBytes(StandardCharsets.UTF_8))); + } + + private static long sizeOf(Path path) { + try { + return Files.size(path); + } catch (IOException e) { + return -1L; + } + } + + private static long modifiedOf(Path path) { + try { + return Files.getLastModifiedTime(path).toMillis(); + } catch (IOException e) { + return -1L; + } + } + + private static final class CheckStamp { + long checkedAt; + String fingerprint; } } diff --git a/src/main/java/i18nupdatemod/core/RuntimePackActivation.java b/src/main/java/i18nupdatemod/core/RuntimePackActivation.java index e35e25c..96a9e57 100644 --- a/src/main/java/i18nupdatemod/core/RuntimePackActivation.java +++ b/src/main/java/i18nupdatemod/core/RuntimePackActivation.java @@ -51,6 +51,13 @@ public static boolean isEnabled() { return enabled; } + /** Keeps a gated-off launch from hooking the client or writing a pack selection. */ + public static void disable() { + synchronized (STATE_LOCK) { + enabled = false; + } + } + /** * Records an explicit Options binding failure, not a generic no-change transform result. * A prepared pack is persisted now; a later prepare call uses the same file fallback. diff --git a/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java b/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java index bcec7a7..6d29e94 100644 --- a/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java +++ b/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java @@ -21,17 +21,26 @@ public final class ResourcePackHttp { private final String i18nBaseUrl; private final String bmclBaseUrl; + private final String githubBaseUrl; private final Semaphore bmclRequests = new Semaphore(BMCL_PARALLELISM, true); private final Semaphore baseRequests = new Semaphore(I18N_PARALLELISM, true); public ResourcePackHttp(String i18nBaseUrl, String bmclBaseUrl) { + this(i18nBaseUrl, bmclBaseUrl, null); + } + + public ResourcePackHttp(String i18nBaseUrl, String bmclBaseUrl, String githubBaseUrl) { this.i18nBaseUrl = root(i18nBaseUrl); this.bmclBaseUrl = bmclBaseUrl == null || bmclBaseUrl.isEmpty() ? null : root(bmclBaseUrl); + this.githubBaseUrl = githubBaseUrl == null || githubBaseUrl.isEmpty() ? null : root(githubBaseUrl); try { httpUrl(this.i18nBaseUrl); if (this.bmclBaseUrl != null) { httpUrl(this.bmclBaseUrl); } + if (this.githubBaseUrl != null) { + httpUrl(this.githubBaseUrl); + } } catch (IOException e) { throw new IllegalArgumentException("Invalid resource source URL", e); } @@ -57,7 +66,8 @@ private static ResourcePackHttp loadConfiguration() { properties.load(input); String base = properties.getProperty("assetBaseUrl"); if (base == null || base.isEmpty()) throw new IOException("Missing assetBaseUrl in build configuration"); - return new ResourcePackHttp(base, properties.getProperty("bmclBaseUrl")); + return new ResourcePackHttp(base, properties.getProperty("bmclBaseUrl"), + properties.getProperty("githubBaseUrl")); } catch (IOException e) { throw new IllegalStateException("Cannot load bundled build configuration", e); } @@ -79,6 +89,35 @@ public InputStream open(String relativePath) throws IOException { return request(i18nBaseUrl + relativePath, baseRequests); } + /** V1 whole packs: every mirror in turn, then GitHub's fixed autobuild release. */ + public InputStream openLegacy(String relativePath) throws IOException { + Objects.requireNonNull(relativePath, "relativePath"); + IOException bmclFailure = null; + if (bmclBaseUrl != null) { + try { + return request(bmclBaseUrl + relativePath, bmclRequests); + } catch (IOException failure) { + bmclFailure = failure; + } + } + try { + return request(i18nBaseUrl + relativePath, baseRequests); + } catch (IOException i18nFailure) { + if (bmclFailure != null) { + i18nFailure.addSuppressed(bmclFailure); + } + if (githubBaseUrl == null) { + throw i18nFailure; + } + try { + return request(githubBaseUrl + relativePath, baseRequests); + } catch (IOException githubFailure) { + githubFailure.addSuppressed(i18nFailure); + throw githubFailure; + } + } + } + private InputStream request(String address, Semaphore limit) throws IOException { URL url = httpUrl(address); acquire(limit); diff --git a/src/main/java/i18nupdatemod/core/v1/LegacyConfig.java b/src/main/java/i18nupdatemod/core/v1/LegacyConfig.java index e3c7e85..98f86ab 100644 --- a/src/main/java/i18nupdatemod/core/v1/LegacyConfig.java +++ b/src/main/java/i18nupdatemod/core/v1/LegacyConfig.java @@ -31,6 +31,20 @@ private static AssetMetaData getAssetMetaData(String minecraftVersion, String lo return current.stream() .filter(it -> it.loader.equalsIgnoreCase(loader)) .findFirst() - .orElseGet(() -> current.get(0)); + .orElseGet(() -> current.stream() + .filter(it -> it.loader.equalsIgnoreCase(downloadKey(loader))) + .findFirst() + .orElseGet(() -> current.get(0))); + } + + /** The metadata only ever names Forge and Fabric assets. */ + private static String downloadKey(String loader) { + if ("NeoForge".equalsIgnoreCase(loader)) { + return "Forge"; + } + if ("Quilt".equalsIgnoreCase(loader)) { + return "Fabric"; + } + return loader; } } diff --git a/src/main/java/i18nupdatemod/core/v1/ResourcePack.java b/src/main/java/i18nupdatemod/core/v1/ResourcePack.java index ddf617d..5d69982 100644 --- a/src/main/java/i18nupdatemod/core/v1/ResourcePack.java +++ b/src/main/java/i18nupdatemod/core/v1/ResourcePack.java @@ -13,13 +13,8 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.security.NoSuchAlgorithmException; -import java.util.concurrent.TimeUnit; public class ResourcePack { - /** - * Limit update check frequency - */ - private static final long UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(1); private final Path filePath; private final Path tmpFilePath; private String remoteMd5; @@ -53,12 +48,6 @@ private boolean isUpToDate(ResourcePackHttp source, String md5FileName) Log.debug("Local file %s not exist.", tmpFilePath); return false; } - //Last update time not exceed gap -> Not Update - if (Files.getLastModifiedTime(tmpFilePath).to(TimeUnit.MILLISECONDS) - > System.currentTimeMillis() - UPDATE_TIME_GAP) { - Log.debug("Local file %s has been updated recently.", tmpFilePath); - return true; - } //Check Update return checkMd5(source, tmpFilePath, md5FileName); } @@ -67,7 +56,7 @@ private boolean checkMd5(ResourcePackHttp source, Path localFile, String md5File throws IOException, NoSuchAlgorithmException { String localMd5 = DigestUtil.md5Hex(localFile); if (remoteMd5 == null) { - try (InputStream input = source.open(md5FileName)) { + try (InputStream input = source.openLegacy(md5FileName)) { ByteArrayOutputStream output = new ByteArrayOutputStream(); byte[] buffer = new byte[4096]; int count; @@ -87,7 +76,7 @@ private void downloadFull(ResourcePackHttp source, String fileName, String md5Fi throws IOException { try { Path downloadTmp = tmpFilePath.resolveSibling(tmpFilePath.getFileName().toString() + ".tmp"); - try (InputStream input = source.open(fileName)) { + try (InputStream input = source.openLegacy(fileName)) { Files.copy(input, downloadTmp, StandardCopyOption.REPLACE_EXISTING); } if (!checkMd5(source, downloadTmp, md5FileName)) { diff --git a/src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java b/src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java index 29e2d80..300b462 100644 --- a/src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java +++ b/src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java @@ -1,6 +1,7 @@ package i18nupdatemod.core.v1; import i18nupdatemod.core.I18nConfig; +import i18nupdatemod.core.I18nUserConfig; import i18nupdatemod.core.ResourcePackConverter; import i18nupdatemod.entity.GameAssetDetail; import i18nupdatemod.entity.ModTranslation; @@ -15,7 +16,9 @@ public static Path update(String minecraftVersion, String loader, List modDomains = new HashSet<>(); for (ModTranslation mod : mods) { - modDomains.add(mod.namespace); + if (!I18nUserConfig.isExcluded(mod.namespace)) { + modDomains.add(mod.namespace); + } } List sources = LegacyResourcePackDownloader.download( plan.packMetaData, loader, resourcePackDirectory, cacheRoot); diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java index 7d1dc36..d34bfd4 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java @@ -41,7 +41,6 @@ public class ResourcePackDownloader { private static final Gson GSON = new Gson(); - private static final long UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(1); private static final long ICON_UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(30); public static Manifest loadManifest(ResourcePackHttp source, String version) throws IOException { @@ -100,7 +99,8 @@ private static Manifest parseManifest(InputStream input) throws IOException { /** * 解析Manifest,根据规则排除模组、生成NS */ - public static Map selectNamespaces(List mods, Manifest manifest) { + public static Map selectNamespaces(List mods, Manifest manifest, + List excludedMods) { if (mods == null) { return new LinkedHashMap<>(); } @@ -110,6 +110,8 @@ public static Map selectNamespaces(List mods, Ma List blackList = manifest.blackList == null ? Collections.emptyList() : manifest.blackList; + List excluded = excludedMods == null + ? Collections.emptyList() : excludedMods; Map rules = manifest.rules == null ? Collections.emptyMap() : manifest.rules; Map selected = new LinkedHashMap<>(); @@ -122,7 +124,7 @@ public static Map selectNamespaces(List mods, Ma Log.warning("Invalid translation namespace: %s", rawNamespace); continue; } - if (blackList.contains(rawNamespace)) { + if (blackList.contains(rawNamespace) || excluded.contains(rawNamespace)) { continue; } @@ -394,12 +396,6 @@ private static void updateMod(ResourcePackHttp source, String assetPath, String // .zip.md5 would collide with the source digest of a namespace ending in ".zip". Path zipMd5File = cached.resolveSibling(cached.getFileName() + "md5"); discardUnverifiedCache(cached, md5File, zipMd5File); - if (Files.isRegularFile(cached) && Files.isRegularFile(md5File) - && Files.getLastModifiedTime(cached).toMillis() - > System.currentTimeMillis() - UPDATE_TIME_GAP) { - return; - } - String remoteMd5 = readRemoteText(source, assetPath + ".md5").trim(); if (!remoteMd5.matches("[0-9a-fA-F]{32}")) { throw new AssetFailure("Invalid asset MD5: " + assetPath); diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java index c7ff498..f2c2858 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java @@ -1,6 +1,7 @@ package i18nupdatemod.core.v2; import i18nupdatemod.core.I18nConfig; +import i18nupdatemod.core.I18nUserConfig; import i18nupdatemod.core.ResourcePackConverter; import i18nupdatemod.core.net.ResourcePackHttp; import i18nupdatemod.entity.GameAssetDetail; @@ -22,7 +23,9 @@ public static Path update(String minecraftVersion, List mods, ResourcePackHttp source = ResourcePackHttp.configured(); ResourcePackDownloader.Manifest manifest = ResourcePackDownloader.loadManifest(source, plan.targetVersion); - Map namespaces = ResourcePackDownloader.selectNamespaces(mods, manifest); + // 用户排除项只影响本次选包,绝不参与缓存清理 + Map namespaces = ResourcePackDownloader.selectNamespaces( + mods, manifest, I18nUserConfig.excludedMods()); // Capture language eligibility before adding Minecraft's baseline fixes. HashSet modDomains = new HashSet<>(namespaces.values()); namespaces.put("minecraft", "minecraft"); diff --git a/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java b/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java index 5979711..eb5fc83 100644 --- a/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java +++ b/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java @@ -15,13 +15,25 @@ public class FabricLoaderMod implements ClientModInitializer { @Override public void onInitializeClient() { Path gameDir = FabricLoader.getInstance().getGameDir(); - Log.setMinecraftLogFile(gameDir); + if (!I18nUpdateMod.prepare(gameDir)) { + return; + } String mcVersion = getMcVersion(); if (mcVersion == null) { Log.warning("Minecraft version not found"); return; } - I18nUpdateMod.init(gameDir, mcVersion, "Fabric", ModUtil.getModsFromModsFolder(gameDir)); + I18nUpdateMod.init(gameDir, mcVersion, isQuilt() ? "Quilt" : "Fabric", + ModUtil.getModsFromModsFolder(gameDir)); + } + + private boolean isQuilt() { + try { + Class.forName("org.quiltmc.loader.api.QuiltLoader", false, getClass().getClassLoader()); + return true; + } catch (ClassNotFoundException ignored) { + return false; + } } private String getMcVersion() { diff --git a/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java b/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java index e3ea9a1..212a9a2 100644 --- a/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java +++ b/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java @@ -8,6 +8,7 @@ import net.minecraft.launchwrapper.LaunchClassLoader; import java.io.File; +import java.nio.file.Path; import java.util.List; //1.6-1.12.2 @@ -15,13 +16,20 @@ public class LaunchWrapperTweaker implements ITweaker { @Override public void acceptOptions(List args, File gameDir, File assetsDir, String profile) { - Log.setMinecraftLogFile(gameDir.toPath()); + if (isDedicatedServer()) { + Log.info("Dedicated server detected, skipping the client resource-pack update."); + return; + } + Path gameDirPath = (gameDir == null ? new File(".") : gameDir).toPath(); + if (!I18nUpdateMod.prepare(gameDirPath)) { + return; + } String mcVersion = getMcVersion(); if (mcVersion == null) { Log.warning("Failed to get minecraft version."); return; } - I18nUpdateMod.init(gameDir.toPath(), mcVersion, "Forge", ModUtil.getModsFromModsFolder(gameDir.toPath())); + I18nUpdateMod.init(gameDirPath, mcVersion, "Forge", ModUtil.getModsFromModsFolder(gameDirPath)); } @Override @@ -39,6 +47,24 @@ public String[] getLaunchArguments() { return new String[0]; } + private boolean isDedicatedServer() { + // 1.6~1.7.10 + if (isServerSide("cpw.mods.fml.relauncher.FMLLaunchHandler")) { + return true; + } + // 1.8~1.12.2 + return isServerSide("net.minecraftforge.fml.relauncher.FMLLaunchHandler"); + } + + private boolean isServerSide(String launchHandler) { + try { + return "SERVER".equalsIgnoreCase( + String.valueOf(Reflection.clazz(launchHandler).get("side()").get())); + } catch (Exception ignored) { + return false; + } + } + private String getMcVersion() { try { // 1.6~1.7.10 diff --git a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java index 324a992..ba5d214 100644 --- a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java +++ b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java @@ -35,19 +35,28 @@ public void initialize(IEnvironment environment) { Log.warning("Minecraft path not found"); return; } - Log.setMinecraftLogFile(minecraftPath.get()); + if (isDedicatedServer(environment)) { + Log.info("Dedicated server detected, skipping the client resource-pack update."); + return; + } + if (!I18nUpdateMod.prepare(minecraftPath.get())) { + return; + } String minecraftVersion = getMinecraftVersion(); if (minecraftVersion == null) { Log.warning("Minecraft version not found"); return; } + boolean neoForge; try { Class.forName("net.neoforged.fml.loading.FMLLoader", false, getClass().getClassLoader()); + neoForge = true; RuntimePackActivation.enable(); } catch (ClassNotFoundException ignored) { - // Forge keeps the existing file-based activation path. + neoForge = false; } - I18nUpdateMod.init(minecraftPath.get(), minecraftVersion, "Forge", ModUtil.getModsFromModsFolder(minecraftPath.get())); + I18nUpdateMod.init(minecraftPath.get(), minecraftVersion, neoForge ? "NeoForge" : "Forge", + ModUtil.getModsFromModsFolder(minecraftPath.get())); } @Override @@ -65,6 +74,13 @@ public void onLoad(IEnvironment env, Set otherServices) throws Incompati return Collections.singletonList(ModLauncherPackTransformer.create()); } + private static boolean isDedicatedServer(IEnvironment environment) { + // Forge and NeoForge name every dedicated-server launch target "*server". + return environment.getProperty(IEnvironment.Keys.LAUNCHTARGET.get()) + .map(target -> target.toLowerCase(Locale.ROOT).contains("server")) + .orElse(false); + } + private String getMinecraftVersion() { // MinecraftForge 1.13~1.20.2 // NeoForge 1.20.1~1.21.8 diff --git a/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java index 28ddc10..5455f12 100644 --- a/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java +++ b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java @@ -30,11 +30,13 @@ public void bootstrap(String[] arguments) { return; } Path gameDir = (Path) loader.get("getGameDir()").get(); - Log.setMinecraftLogFile(gameDir); + if (!I18nUpdateMod.prepare(gameDir)) { + return; + } // FML consumes --fml.mcVersion before calling bootstrappers; do not parse arguments. String version = (String) loader.get("getVersionInfo()").get("mcVersion()").get(); RuntimePackActivation.enable(); - I18nUpdateMod.init(gameDir, version, "Forge", ModUtil.getModsFromModsFolder(gameDir)); + I18nUpdateMod.init(gameDir, version, "NeoForge", ModUtil.getModsFromModsFolder(gameDir)); } catch (Exception e) { Log.warning("Failed to initialize NeoForge resource pack update: %s", e); } diff --git a/src/test/java/i18nupdatemod/core/GameConfigTest.java b/src/test/java/i18nupdatemod/core/GameConfigTest.java new file mode 100644 index 0000000..9a33841 --- /dev/null +++ b/src/test/java/i18nupdatemod/core/GameConfigTest.java @@ -0,0 +1,33 @@ +package i18nupdatemod.core; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GameConfigTest { + @TempDir + Path gameDir; + + @Test + void emptyResourcePacksValueAddsThePackInsteadOfAborting() throws Exception { + Path options = gameDir.resolve("options.txt"); + Files.write(options, "lang:zh_cn\nresourcePacks:\nfov:70\n".getBytes(StandardCharsets.UTF_8)); + + assertEquals(Collections.emptyList(), GameConfig.readResourcePacks(options)); + + GameConfig.addResourcePack(options, "Minecraft-Mod-Language-Modpack", + "file/Minecraft-Mod-Language-Modpack-Converted-1.9.zip"); + assertEquals(Collections.singletonList("file/Minecraft-Mod-Language-Modpack-Converted-1.9.zip"), + GameConfig.readResourcePacks(options)); + String content = new String(Files.readAllBytes(options), StandardCharsets.UTF_8); + assertTrue(content.contains("lang:zh_cn"), content); + assertTrue(content.contains("fov:70"), content); + } +} diff --git a/src/test/java/i18nupdatemod/core/ResourcePackUpdaterTest.java b/src/test/java/i18nupdatemod/core/ResourcePackUpdaterTest.java new file mode 100644 index 0000000..c545437 --- /dev/null +++ b/src/test/java/i18nupdatemod/core/ResourcePackUpdaterTest.java @@ -0,0 +1,105 @@ +package i18nupdatemod.core; + +import i18nupdatemod.entity.ModTranslation; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ResourcePackUpdaterTest { + @TempDir + Path gameDir; + + @Test + void unchangedInputsWithinWindowSkip() throws Exception { + Path cache = gameDir.resolve("cache"); + Path pack = pack(); + ResourcePackUpdater.markChecked(cache, "fp"); + assertTrue(ResourcePackUpdater.checkedRecently(cache, pack, hours(24), "fp")); + } + + @Test + void changedFingerprintChecksImmediately() throws Exception { + Path cache = gameDir.resolve("cache"); + Path pack = pack(); + ResourcePackUpdater.markChecked(cache, "before"); + assertFalse(ResourcePackUpdater.checkedRecently(cache, pack, hours(24), "after")); + } + + @Test + void expiredWindowChecksAgain() throws Exception { + Path cache = gameDir.resolve("cache"); + Path pack = pack(); + writeStamp(cache, System.currentTimeMillis() - hours(25), "fp"); + assertFalse(ResourcePackUpdater.checkedRecently(cache, pack, hours(24), "fp")); + } + + @Test + void zeroWindowAlwaysChecks() throws Exception { + Path cache = gameDir.resolve("cache"); + Path pack = pack(); + ResourcePackUpdater.markChecked(cache, "fp"); + assertFalse(ResourcePackUpdater.checkedRecently(cache, pack, 0L, "fp")); + } + + @Test + void missingPackChecksAgain() throws Exception { + Path cache = gameDir.resolve("cache"); + ResourcePackUpdater.markChecked(cache, "fp"); + assertFalse(ResourcePackUpdater.checkedRecently( + cache, gameDir.resolve("missing.zip"), hours(24), "fp")); + } + + @Test + void addingAModChangesTheFingerprint() throws Exception { + ModTranslation first = mod("jei", "jei.jar"); + ModTranslation second = mod("create", "create.jar"); + String before = ResourcePackUpdater.fingerprint("1.20.1", Collections.singletonList(first)); + String after = ResourcePackUpdater.fingerprint("1.20.1", Arrays.asList(first, second)); + assertNotEquals(before, after); + assertEquals(after, ResourcePackUpdater.fingerprint("1.20.1", Arrays.asList(second, first))); + } + + @Test + void updatingAModFileChangesTheFingerprint() throws Exception { + ModTranslation mod = mod("jei", "jei.jar"); + String before = ResourcePackUpdater.fingerprint("1.20.1", Collections.singletonList(mod)); + Files.write(mod.source, new byte[]{1, 2, 3}); + assertNotEquals(before, + ResourcePackUpdater.fingerprint("1.20.1", Collections.singletonList(mod))); + } + + private ModTranslation mod(String namespace, String fileName) throws Exception { + Path mods = Files.createDirectories(gameDir.resolve("mods")); + Path jar = mods.resolve(fileName); + Files.write(jar, new byte[]{1}); + return new ModTranslation(namespace, null, null, jar, null); + } + + private Path pack() throws Exception { + Path pack = gameDir.resolve("pack.zip"); + Files.write(pack, new byte[]{1}); + return pack; + } + + private static void writeStamp(Path cache, long checkedAt, String fingerprint) throws Exception { + Files.createDirectories(cache); + Files.write(cache.resolve("check-stamp.json"), + ("{\"checkedAt\":" + checkedAt + ",\"fingerprint\":\"" + fingerprint + "\"}") + .getBytes(StandardCharsets.UTF_8)); + } + + private static long hours(long value) { + return TimeUnit.HOURS.toMillis(value); + } +} diff --git a/src/test/java/i18nupdatemod/core/net/LegacyFallbackTest.java b/src/test/java/i18nupdatemod/core/net/LegacyFallbackTest.java new file mode 100644 index 0000000..694fdfb --- /dev/null +++ b/src/test/java/i18nupdatemod/core/net/LegacyFallbackTest.java @@ -0,0 +1,93 @@ +package i18nupdatemod.core.net; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LegacyFallbackTest { + @Test + void bmclStatusFailureStillTriesTheI18nMirrorBeforeGithub() throws Exception { + try (Mirror bmcl = new Mirror(404, "not here"); + Mirror i18n = new Mirror(200, "i18n pack"); + Mirror github = new Mirror(200, "github pack")) { + ResourcePackHttp source = new ResourcePackHttp(i18n.baseUrl(), bmcl.baseUrl(), github.baseUrl()); + assertEquals("i18n pack", read(source.openLegacy("pack.zip"))); + assertEquals(0, github.hits.get()); + } + } + + @Test + void bmclConnectionFailureStillTriesTheI18nMirrorBeforeGithub() throws Exception { + Mirror bmcl = new Mirror(200, "unreachable"); + bmcl.close(); + try (Mirror i18n = new Mirror(200, "i18n pack"); + Mirror github = new Mirror(200, "github pack")) { + ResourcePackHttp source = new ResourcePackHttp(i18n.baseUrl(), bmcl.baseUrl(), github.baseUrl()); + assertEquals("i18n pack", read(source.openLegacy("pack.zip"))); + assertEquals(0, github.hits.get()); + } + } + + @Test + void githubIsUsedOnlyAfterBothMirrorsFail() throws Exception { + try (Mirror bmcl = new Mirror(500, "broken"); + Mirror i18n = new Mirror(404, "not here"); + Mirror github = new Mirror(200, "github pack")) { + ResourcePackHttp source = new ResourcePackHttp(i18n.baseUrl(), bmcl.baseUrl(), github.baseUrl()); + assertEquals("github pack", read(source.openLegacy("pack.zip"))); + } + } + + private static String read(InputStream input) throws IOException { + try (InputStream stream = input) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int count; + while ((count = stream.read(buffer)) != -1) { + output.write(buffer, 0, count); + } + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } + } + + private static final class Mirror implements AutoCloseable { + private final HttpServer server; + private final int status; + private final String body; + private final AtomicInteger hits = new AtomicInteger(); + + Mirror(int status, String body) throws IOException { + this.status = status; + this.body = body; + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + hits.incrementAndGet(); + byte[] payload = this.body.getBytes(StandardCharsets.UTF_8); + try { + exchange.sendResponseHeaders(this.status, payload.length); + exchange.getResponseBody().write(payload); + } finally { + exchange.close(); + } + }); + server.start(); + } + + String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/"; + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/src/test/java/i18nupdatemod/core/v2/ResourcePackCacheTest.java b/src/test/java/i18nupdatemod/core/v2/ResourcePackCacheTest.java index 5939e7d..28abf6e 100644 --- a/src/test/java/i18nupdatemod/core/v2/ResourcePackCacheTest.java +++ b/src/test/java/i18nupdatemod/core/v2/ResourcePackCacheTest.java @@ -47,7 +47,8 @@ void repairsChangedBytesEvenWithUnchangedSizeAndFreshTimestamp() throws Exceptio assertEquals(2, server.archives.get()); assertEquals(2, server.checksums.get()); fetch(server); - assertEquals(2, server.checksums.get(), "Verified fresh cache retains its network TTL"); + assertEquals(3, server.checksums.get(), "Each run re-validates the cached ZIP against the remote digest"); + assertEquals(2, server.archives.get()); } } @@ -77,7 +78,8 @@ void missingDecodedDigestRedownloadsRatherThanSigningUnknownBytes() throws Excep assertTranslation(zip); assertEquals(2, server.archives.get()); fetch(server); - assertEquals(2, server.checksums.get(), "Only the first migration requires redownloading"); + assertEquals(3, server.checksums.get(), "Every run re-validates; only a missing digest forces a redownload"); + assertEquals(2, server.archives.get()); } } @@ -135,7 +137,7 @@ void namespaceEndingInZipDoesNotOverwriteAnotherCachesDigest() throws Exception fetch(server); ResourcePackDownloader.download("test", Collections.singletonMap("fixture.zip", "second"), Collections.emptyList(), cache, server.source); - assertEquals(2, server.checksums.get(), "Both distinct namespaces must retain valid fresh caches"); + assertEquals(4, server.checksums.get(), "Each run re-validates both namespaces against their remote digests"); assertEquals(2, server.archives.get()); } } diff --git a/src/test/java/i18nupdatemod/core/v2/ResourcePackSelectionTest.java b/src/test/java/i18nupdatemod/core/v2/ResourcePackSelectionTest.java new file mode 100644 index 0000000..7167edf --- /dev/null +++ b/src/test/java/i18nupdatemod/core/v2/ResourcePackSelectionTest.java @@ -0,0 +1,28 @@ +package i18nupdatemod.core.v2; + +import i18nupdatemod.entity.ModTranslation; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ResourcePackSelectionTest { + @Test + void excludedNamespacesAreSkippedBySelectionOnly() { + ResourcePackDownloader.Manifest manifest = new ResourcePackDownloader.Manifest(); + manifest.blackList.add("create"); + + Map selected = ResourcePackDownloader.selectNamespaces( + Arrays.asList(mod("jei"), mod("create"), mod("other")), manifest, + Collections.singletonList("jei")); + + assertEquals(Collections.singletonMap("other", "other"), selected); + } + + private static ModTranslation mod(String namespace) { + return new ModTranslation(namespace, null, null, null, null); + } +}