From 6eb5672a7917a0a300dadb1b1d2cb28aad497539 Mon Sep 17 00:00:00 2001 From: Kir_Antipov Date: Mon, 27 Mar 2023 11:33:12 +0000 Subject: [PATCH] Implemented Minecraft version normalization scheme ~~Stolen~~ Adapted from FabricMC --- .../minecraft/minecraft-version-lookup.ts | 409 ++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 src/games/minecraft/minecraft-version-lookup.ts diff --git a/src/games/minecraft/minecraft-version-lookup.ts b/src/games/minecraft/minecraft-version-lookup.ts new file mode 100644 index 0000000..27602ea --- /dev/null +++ b/src/games/minecraft/minecraft-version-lookup.ts @@ -0,0 +1,409 @@ +import { asArrayLike, isIterable } from "@/utils/collections"; +import { VersionRange, parseVersionRange } from "@/utils/versioning"; +import { MinecraftVersion, MinecraftVersionManifestEntry } from "./minecraft-version"; +import { MinecraftVersionType } from "./minecraft-version-type"; + +/** + * The regular expression pattern to match various Minecraft version strings. + */ +const VERSION_PATTERN = ( + "0\\.\\d+(?:\\.\\d+)?a?(?:_\\d+)?|" + + "\\d+\\.\\d+(?:\\.\\d+)?(?:-pre\\d+| Pre-[Rr]elease \\d+|-rc\\d+| [Rr]elease Candidate \\d+)?|" + + "\\d+w\\d+(?:[a-z]+|~)|" + + "[a-c]\\d\\.\\d+(?:\\.\\d+)?[a-z]?(?:_\\d+)?[a-z]?|" + + "(Alpha|Beta) v?\\d+\\.\\d+(?:\\.\\d+)?[a-z]?(?:_\\d+)?[a-z]?|" + + "Inf?dev (?:0\\.31 )?\\d+(?:-\\d+)?|" + + "(?:rd|inf)-\\d+|" + + "(?:.*[Ee]xperimental [Ss]napshot )(?:\\d+)" +); + +/** + * Regular expression for matching and validating Minecraft version strings. + */ +const VERSION_REGEX = new RegExp(VERSION_PATTERN); + +/** + * Regular expression for matching and validating release Minecraft versions. + */ +const RELEASE_REGEX = /\d+\.\d+(\.\d+)?/; + +/** + * Regular expression for matching and validating pre-release Minecraft versions. + */ +const PRE_RELEASE_REGEX = /.+(?:-pre| Pre-[Rr]elease )(\d+)/; + +/** + * Regular expression for matching and validating release candidate Minecraft versions. + */ +const RELEASE_CANDIDATE_REGEX = /.+(?:-rc| [Rr]elease Candidate )(\d+)/; + +/** + * Regular expression for matching and validating snapshot Minecraft versions. + */ +const SNAPSHOT_REGEX = /(?:Snapshot )?(\d+)w0?(0|[1-9]\d*)([a-z])/; + +/** + * Regular expression for matching and validating experimental snapshot Minecraft versions. + */ +const EXPERIMENTAL_REGEX = /(?:.*[Ee]xperimental [Ss]napshot )(\d+)/; + +/** + * Regular expression for matching and validating beta Minecraft versions. + */ +const BETA_REGEX = /(?:b|Beta v?)1\.(\d+(\.\d+)?[a-z]?(_\d+)?[a-z]?)/; + +/** + * Regular expression for matching and validating alpha Minecraft versions. + */ +const ALPHA_REGEX = /(?:a|Alpha v?)[01]\.(\d+(\.\d+)?[a-z]?(_\d+)?[a-z]?)/; + +/** + * Regular expression for matching and validating in-development Minecraft versions. + */ +const INDEV_REGEX = /(?:inf-|Inf?dev )(?:0\.31 )?(\d+(-\d+)?)/; + +/** + * Represents the range of legacy Minecraft versions. + * + * It is used to determine if a given Minecraft version string is considered a legacy version or not. + * In our case, versions less than or equal to `1.16` are considered legacy. + */ +const LEGACY_VERSION_RANGE = parseVersionRange("<=1.16"); + +/** + * A map of special Minecraft versions (e.g., April Fools' ones) and their normalized counterparts. + */ +const SPECIAL_VERSIONS: ReadonlyMap = new Map([ + ["13w12~", "1.5.1-alpha.13.12.a"], + ["2point0_red", "1.5.2-red"], + ["2point0_purple", "1.5.2-purple"], + ["2point0_blue", "1.5.2-blue"], + ["15w14a", "1.8.4-alpha.15.14.a+loveandhugs"], + ["1.RV-Pre1", "1.9.2-rv+trendy"], + ["3D Shareware v1.34", "1.14-alpha.19.13.shareware"], + ["1.14.3 - Combat Test", "1.14.3-rc.4.combat.1"], + ["Combat Test 2", "1.14.5-combat.2"], + ["Combat Test 3", "1.14.5-combat.3"], + ["Combat Test 4", "1.15-rc.3.combat.4"], + ["Combat Test 5", "1.15.2-rc.2.combat.5"], + ["20w14~", "1.16-alpha.20.13.inf"], + ["Combat Test 6", "1.16.2-beta.3.combat.6"], + ["Combat Test 7", "1.16.3-combat.7"], + ["1.16_combat-2", "1.16.3-combat.7.b"], + ["1.16_combat-3", "1.16.3-combat.7.c"], + ["1.16_combat-4", "1.16.3-combat.8"], + ["1.16_combat-5", "1.16.3-combat.8.b"], + ["1.16_combat-6", "1.16.3-combat.8.c"], + ["23w13a_or_b", "1.20-alpha.23.13.ab"], +]); + +/** + * Normalizes a given Minecraft version string. + * + * @param version - The Minecraft version string to normalize. + * @param versions - Optional Minecraft version manifest entries. + * @param index - Optional index of the Minecraft version in the manifest entries. + * + * @returns The normalized Minecraft version string. + */ +export function normalizeMinecraftVersion(version: string, versions?: MinecraftVersionManifestEntry[], index?: number): string { + const releaseVersion = versions ? findNearestReleaseMinecraftVersion(versions, index) : version.match(RELEASE_REGEX)?.[0]; + return normalizeUnknownMinecraftVersion(version, releaseVersion); +} + +/** + * Normalizes a Minecraft version range. + * + * @param range - The version range to normalize. + * @param versions - A map of Minecraft versions and their corresponding ids. + * @param versionRegex - A regular expression for matching Minecraft versions. + * + * @returns The normalized Minecraft version range. + */ +export function normalizeMinecraftVersionRange(range: string | Iterable | VersionRange, versions: ReadonlyMap, versionRegex: RegExp): VersionRange { + if (!isIterable(range)) { + return range; + } + + const ranges = typeof range === "string" ? [range] : asArrayLike(range); + const normalizedRanges = ranges.map((r: string) => r.replaceAll(versionRegex, x => { + const version = versions.get(x); + if (version) { + return String(version.version); + } + + return normalizeMinecraftVersion(x); + })); + + return parseVersionRange(normalizedRanges); +} + +/** + * Generates a regular expression for matching Minecraft versions. + * + * @param versions - Optional collection of Minecraft versions that should satisfy the resulting regex. + * + * @returns A regular expression for matching Minecraft versions. + */ +export function getMinecraftVersionRegExp(versions?: Iterable): RegExp { + if (!versions) { + return VERSION_REGEX; + } + + let pattern = VERSION_PATTERN; + for (const version of versions) { + if (version.match(VERSION_REGEX)?.[0] !== version) { + pattern = `${version.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d")}|${pattern}`; + } + } + return pattern === VERSION_PATTERN ? VERSION_REGEX : new RegExp(pattern, "gs"); +} + +/** + * Normalizes an unknown Minecraft version. + * + * The normalization process formats the version string to provide better compatibility with + * FabricMC's normalization scheme. This may involve appending the release version, converting + * snapshot, experimental, or pre-release information, or transforming old version strings. + * + * @param version - The Minecraft version string to normalize. + * @param releaseVersion - Optional release version string for context. + * + * @returns The normalized Minecraft version string. + * + * @remarks + * + * Original algorithm from FabricMC: + * https://github.com/FabricMC/fabric-loader/blob/HEAD/minecraft/src/main/java/net/fabricmc/loader/impl/game/minecraft/McVersionLookup.java + */ +function normalizeUnknownMinecraftVersion(version: string, releaseVersion?: string): string { + if (SPECIAL_VERSIONS.has(version)) { + return SPECIAL_VERSIONS.get(version); + } + + if (!releaseVersion || version === releaseVersion) { + return normalizeOldMinecraftVersion(version); + } + + let match: RegExpMatchArray; + if (match = version.match(EXPERIMENTAL_REGEX)) { + return `${releaseVersion}-Experimental.${match[1]}`; + } + + if (version.startsWith(releaseVersion)) { + if (match = version.match(RELEASE_CANDIDATE_REGEX)) { + const rcBuild = releaseVersion === "1.16" ? String(8 + (+match[1])) : match[1]; + version = `rc.${rcBuild}`; + } else if (match = version.match(PRE_RELEASE_REGEX)) { + const isLegacy = isLegacyMinecraftVersion(releaseVersion); + version = `${isLegacy ? "rc" : "beta"}.${match[1]}`; + } + } else if (match = version.match(SNAPSHOT_REGEX)) { + version = `alpha.${match[1]}.${match[2]}.${match[3]}`; + } else { + version = normalizeOldMinecraftVersion(version); + } + return `${releaseVersion}-${version}`; +} + +/** + * Normalizes an old Minecraft version by converting version components like alpha, beta, + * and indev to a more standard format, as well as removing unnecessary characters and correcting + * the separator placements. + * + * @param version - The old Minecraft version string to normalize. + * + * @returns The normalized Minecraft version string. + */ +function normalizeOldMinecraftVersion(version: string): string { + let matcher: RegExpMatchArray; + if (matcher = version.match(BETA_REGEX)) { + version = `1.0.0-beta.${matcher[1]}`; + } else if (matcher = version.match(ALPHA_REGEX)) { + version = `1.0.0-alpha.${matcher[1]}`; + } else if (matcher = version.match(INDEV_REGEX)) { + version = `0.31.${matcher[1]}`; + } else if (version.startsWith("c0.")) { + version = version.substring(1); + } else if (version.startsWith("rd-")) { + version = version.substring(3); + if (version === "20090515") { + version = "150000"; + } + version = `0.0.0-rd.${version}`; + } + + let normalized = ""; + let wasDigit = false; + let wasLeadingZero = false; + let wasSeparator = false; + let hasHyphen = false; + for (let i = 0; i < version.length; ++i) { + let c = version.charAt(i); + if (c >= "0" && c <= "9") { + if (i > 0 && !wasDigit && !wasSeparator) { + normalized += "."; + } else if (wasDigit && wasLeadingZero) { + normalized = normalized.substring(0, normalized.length - 1); + } + wasLeadingZero = c === "0" && (!wasDigit || wasLeadingZero); + wasSeparator = false; + wasDigit = true; + } else if (c === "." || c === "-") { + if (wasSeparator) { + continue; + } + + wasSeparator = true; + wasDigit = false; + } else if ((c < "A" || c > "Z") && (c < "a" || c > "z")) { + if (wasSeparator) { + continue; + } + + c = "."; + wasSeparator = true; + wasDigit = false; + } else { + if (wasDigit) { + normalized += hasHyphen ? "." : "-"; + hasHyphen = true; + } + wasSeparator = false; + wasDigit = false; + } + + if (c === "-") { + hasHyphen = true; + } + normalized += c; + } + + let start = 0; + while (start < normalized.length && normalized.charAt(start) === ".") { + ++start; + } + + let end = normalized.length; + while (end > start && normalized.charAt(end - 1) === ".") { + --end; + } + + return normalized.substring(start, end); +} + +/** + * Finds the nearest release Minecraft version to a given index in the provided version manifest entries. + * + * This is used to determine the release version context for non-release versions (e.g., snapshots). + * + * @param versions - An array of Minecraft version manifest entries. + * @param index - The index of the version for which to find the nearest release version. + * + * @returns The nearest release Minecraft version string, or `undefined` if not found. + */ +function findNearestReleaseMinecraftVersion(versions: MinecraftVersionManifestEntry[], index: number): string | undefined { + if (versions[index].type === MinecraftVersionType.RELEASE) { + return versions[index].id; + } + + if (versions[index].type !== MinecraftVersionType.SNAPSHOT) { + return undefined; + } + + const match = versions[index].id.match(RELEASE_REGEX); + if (match) { + return match[0]; + } + + const snapshot = versions[index].id.match(SNAPSHOT_REGEX); + if (snapshot) { + const year = +snapshot[1]; + const week = +snapshot[2]; + + const hardcodedSnapshotVersion = findNearestReleaseMinecraftVersionBySnapshotDate(year, week); + if (hardcodedSnapshotVersion) { + return hardcodedSnapshotVersion; + } + } + + for (let i = index - 1; i >= 0; --i) { + if (versions[i].type === MinecraftVersionType.RELEASE) { + return versions[i].id; + } + } + + for (let i = index + 1; i < versions.length; ++i) { + if (versions[i].type !== MinecraftVersionType.RELEASE) { + continue; + } + + const match = versions[i].id.match(/(\d+)\.(\d+)(?:\.(\d+))?/); + if (match) { + return `${match[1]}.${match[2]}.${(+match[3] || 0) + 1}`; + } + } + + return undefined; +} + +/** + * Finds the nearest release Minecraft version based on the snapshot year and week. + * + * This function is required because the order of versions in the version manifest may not + * always correspond to their actual release order, especially for older versions. + * By using hardcoded release versions for specific date ranges, we can determine the nearest + * release version more accurately for certain snapshots. + * + * @param year - The snapshot year. + * @param week - The snapshot week. + * + * @returns The nearest release Minecraft version string, or `undefined` if not found. + * + * @remarks + * + * Original algorithm from FabricMC: + * https://github.com/FabricMC/fabric-loader/blob/HEAD/minecraft/src/main/java/net/fabricmc/loader/impl/game/minecraft/McVersionLookup.java#L267 + */ +function findNearestReleaseMinecraftVersionBySnapshotDate(year: number, week: number) : string | undefined { + if (year === 23 && week >= 12) { + return "1.20"; + } + + if (year === 20 && week >= 45 || year === 21 && week <= 20) { + return "1.17"; + } + + if (year === 15 && week >= 31 || year === 16 && week <= 7) { + return "1.9"; + } + + if (year === 14 && week >= 2 && week <= 34) { + return "1.8"; + } + + if (year === 13 && week >= 47 && week <= 49) { + return "1.7.4"; + } + + if (year === 13 && week >= 36 && week <= 43) { + return "1.7.2"; + } + + if (year === 13 && week >= 16 && week <= 26) { + return "1.6"; + } + + return undefined; +} + +/** + * Determines if a Minecraft version is considered legacy based on its version string. + * + * @param version - The Minecraft version string to evaluate. + * + * @returns `true` if the version is considered legacy; otherwise, `false`. + */ +function isLegacyMinecraftVersion(version: string): boolean { + return LEGACY_VERSION_RANGE.includes(version); +}