orzmc 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
orzmc/__init__.py ADDED
@@ -0,0 +1,171 @@
1
+ """OrzMC core library.
2
+
3
+ This module is the **public API contract**. The application layer (orzmc-app)
4
+ and any external project should only import from here; internals live under
5
+ ``domain`` / ``infra`` / ``core`` / ``services``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+
12
+ from orzmc.core.mojang import VersionEntry
13
+ from orzmc.domain.java import DEFAULT_JAVA_MAJOR, required_java_major
14
+ from orzmc.domain.launch import DEFAULT_MAIN_CLASS, build_launch_command, game_args, jvm_args
15
+ from orzmc.domain.libraries import Library, resolve_libraries
16
+ from orzmc.domain.options import RuntimeOptions
17
+ from orzmc.domain.paths import DEFAULT_ROOT, PathLayout
18
+ from orzmc.domain.types import GameType
19
+ from orzmc.infra.fs import FileStore
20
+ from orzmc.infra.log import NullReporter, Reporter, RichReporter
21
+ from orzmc.infra.progress import NullProgress, ProgressSink, RichProgress
22
+ from orzmc.services.backup import Backup
23
+ from orzmc.services.client import ClientService
24
+ from orzmc.services.context import AppContext, Services
25
+ from orzmc.services.server import ServerService
26
+ from orzmc.services.versions import InstalledVersion, VersionManager
27
+ from orzmc.version import __version__
28
+
29
+ __all__ = [
30
+ "DEFAULT_JAVA_MAJOR",
31
+ "DEFAULT_MAIN_CLASS",
32
+ "DEFAULT_ROOT",
33
+ "AppContext",
34
+ "Backup",
35
+ "ClientService",
36
+ "FileStore",
37
+ "GameType",
38
+ "InstalledVersion",
39
+ "Library",
40
+ "NullProgress",
41
+ "NullReporter",
42
+ "PathLayout",
43
+ "ProgressSink",
44
+ # protocols & impls
45
+ "Reporter",
46
+ "RichProgress",
47
+ "RichReporter",
48
+ # domain
49
+ "RuntimeOptions",
50
+ "ServerService",
51
+ # services
52
+ "Services",
53
+ "VersionEntry",
54
+ "VersionManager",
55
+ # versions
56
+ "__version__",
57
+ "backup_world",
58
+ "build_launch_command",
59
+ "deploy_server",
60
+ "game_args",
61
+ "install_java",
62
+ "jvm_args",
63
+ # entry points
64
+ "launch_client",
65
+ "list_versions",
66
+ "remote_version_catalog",
67
+ "remote_versions",
68
+ "remove_version",
69
+ "required_java_major",
70
+ "resolve_libraries",
71
+ ]
72
+
73
+ # ── entry points ─────────────────────────────────────────────────────────────
74
+
75
+
76
+ def launch_client(
77
+ options: RuntimeOptions,
78
+ reporter: Reporter | None = None,
79
+ sink: ProgressSink | None = None,
80
+ confirm_java: Callable[[int, bool], bool] | None = None,
81
+ ) -> int:
82
+ """Launch the Minecraft client for ``options`` (auto-installs everything).
83
+
84
+ ``confirm_java`` (optional) is consulted before installing a Java runtime.
85
+ """
86
+ base = Services(options, reporter=reporter, sink=sink)
87
+ version = options.version or base.mojang.latest_release_id()
88
+ if not version:
89
+ raise RuntimeError("无法确定 Minecraft 版本")
90
+ return ClientService(base.for_version(version)).run(confirm_java=confirm_java)
91
+
92
+
93
+ def deploy_server(
94
+ options: RuntimeOptions,
95
+ reporter: Reporter | None = None,
96
+ sink: ProgressSink | None = None,
97
+ confirm_java: Callable[[int, bool], bool] | None = None,
98
+ confirm_eula: Callable[[], bool] | None = None,
99
+ on_line: Callable[[str], None] | None = None,
100
+ ) -> int:
101
+ """Deploy and run a server for ``options`` (auto-installs everything).
102
+
103
+ ``confirm_eula`` (optional) accepts the Minecraft EULA interactively when
104
+ ``options.yes`` is not set. ``on_line`` receives streamed server output.
105
+ """
106
+ base = Services(options, reporter=reporter, sink=sink)
107
+ version = options.version or base.mojang.latest_release_id()
108
+ if not version:
109
+ raise RuntimeError("无法确定 Minecraft 版本")
110
+ return ServerService(base.for_version(version)).run(
111
+ confirm_java=confirm_java, confirm_eula=confirm_eula, on_line=on_line
112
+ )
113
+
114
+
115
+ def list_versions(root_dir: str | None = None) -> list[InstalledVersion]:
116
+ """Enumerate installed versions (client / server types) under ``root_dir``."""
117
+ return VersionManager(root_dir=root_dir).list_versions()
118
+
119
+
120
+ def remote_version_catalog(root_dir: str | None = None, update: bool = False) -> list[VersionEntry]:
121
+ """List all Mojang manifest versions (any type, newest first); installs nothing."""
122
+ options = RuntimeOptions(root_dir=root_dir or DEFAULT_ROOT)
123
+ return Services(options).mojang.version_entries(update=update)
124
+
125
+
126
+ def remote_versions(root_dir: str | None = None, update: bool = False) -> list[str]:
127
+ """List Mojang release version ids (fetches/caches the manifest, installs nothing)."""
128
+ return [e.id for e in remote_version_catalog(root_dir, update) if e.is_release]
129
+
130
+
131
+ def remove_version(
132
+ version: str,
133
+ *,
134
+ is_client: bool = True,
135
+ game_type: str | None = None,
136
+ root_dir: str | None = None,
137
+ yes: bool = False,
138
+ reporter: Reporter | None = None,
139
+ confirm: Callable[[str], bool] | None = None,
140
+ ) -> bool:
141
+ """Remove a version's client or a specific server type. ``confirm`` is
142
+ consulted unless ``yes`` is set."""
143
+ manager = VersionManager(root_dir=root_dir)
144
+ return manager.remove(version, is_client=is_client, game_type=game_type, yes=yes, confirm=confirm)
145
+
146
+
147
+ def backup_world(
148
+ version: str,
149
+ game_type: str = "vanilla",
150
+ root_dir: str | None = None,
151
+ reporter: Reporter | None = None,
152
+ ) -> str:
153
+ """Back up ``versions/<version>/server/<game_type>/world`` to ``backup/worlds/``."""
154
+ return Backup(reporter=reporter, root_dir=root_dir).backup_world(version, game_type)
155
+
156
+
157
+ def install_java(
158
+ major: int,
159
+ *,
160
+ need_jdk: bool = False,
161
+ root_dir: str | None = None,
162
+ reporter: Reporter | None = None,
163
+ sink: ProgressSink | None = None,
164
+ confirm: Callable[[int, bool], bool] | None = None,
165
+ ) -> str:
166
+ """Ensure a sandboxed Java ``major`` is installed under ``root_dir/java/``.
167
+
168
+ Returns the managed ``bin/java`` path.
169
+ """
170
+ options = RuntimeOptions(root_dir=root_dir or DEFAULT_ROOT)
171
+ return Services(options, reporter=reporter, sink=sink).java_env.resolve(major, need_jdk=need_jdk, confirm=confirm)
@@ -0,0 +1,21 @@
1
+ """Client add-on strategies (``ClientProvider`` registry).
2
+
3
+ Importing this package registers the vanilla / fabric / forge providers so
4
+ ``ClientProvider.for_type`` resolves them. Library-internal — none of these
5
+ names are part of the public API contract in ``orzmc/__init__``.
6
+ """
7
+
8
+ from orzmc.core.client.base import BuildJavaSeam, ClientPrepare, ClientProvider, DownloadSeam
9
+ from orzmc.core.client.fabric import FabricProvider
10
+ from orzmc.core.client.forge import ForgeProvider
11
+ from orzmc.core.client.vanilla import VanillaProvider
12
+
13
+ __all__ = [
14
+ "BuildJavaSeam",
15
+ "ClientPrepare",
16
+ "ClientProvider",
17
+ "DownloadSeam",
18
+ "FabricProvider",
19
+ "ForgeProvider",
20
+ "VanillaProvider",
21
+ ]
@@ -0,0 +1,85 @@
1
+ """ClientProvider strategy: resolve an optional launch addon for a game type.
2
+
3
+ Mirror of ``core/server``: providers receive every dependency as an injected
4
+ seam (``ClientPrepare``), so this package never imports ``orzmc.services``.
5
+ The orchestration seams ``download`` / ``resolve_build_java`` are supplied by
6
+ ``ClientService`` from the services layer — a textbook dependency inversion.
7
+
8
+ Each concrete provider holds all knowledge of one client type in a single
9
+ file, so editing one type never affects another: vanilla is a no-op, fabric
10
+ wraps the fabric-meta profile json, forge runs the official installer and
11
+ harvests its generated client jar.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from abc import ABC, abstractmethod
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+ from typing import Any, Protocol
20
+
21
+ from orzmc.core.profiles import ProfileAddon
22
+ from orzmc.domain.paths import PathLayout
23
+ from orzmc.domain.types import GameType
24
+ from orzmc.infra.fs import FileStore
25
+ from orzmc.infra.http import HttpClient
26
+ from orzmc.infra.log import Reporter
27
+ from orzmc.infra.runner import ProcessRunner
28
+
29
+
30
+ class DownloadSeam(Protocol):
31
+ """Single-file download that skips an already-valid cache (download_file)."""
32
+
33
+ def __call__(self, url: str, dest: str, desc: str, sha1: str | None = None, force: bool = False) -> bool: ...
34
+
35
+
36
+ class BuildJavaSeam(Protocol):
37
+ """Resolve a sandboxed java binary for an install step (JavaEnv.resolve)."""
38
+
39
+ def __call__(
40
+ self, major: int, need_jdk: bool = False, confirm: Callable[[int, bool], bool] | None = None
41
+ ) -> str: ...
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ClientPrepare:
46
+ """Injected seams + parsed Mojang data a ClientProvider needs for an addon."""
47
+
48
+ version: str
49
+ version_json: dict[str, Any]
50
+ major: int
51
+ force_download: bool
52
+ confirm_java: Callable[[int, bool], bool] | None
53
+ # ── infra / domain seams ────────────────────────────────────────────────
54
+ paths: PathLayout
55
+ fs: FileStore
56
+ reporter: Reporter
57
+ http: HttpClient
58
+ process: ProcessRunner
59
+ # ── orchestration injections (services layer) ───────────────────────────
60
+ download: DownloadSeam
61
+ resolve_build_java: BuildJavaSeam
62
+
63
+
64
+ _REGISTRY: dict[GameType, ClientProvider] = {}
65
+
66
+
67
+ class ClientProvider(ABC):
68
+ """Strategy that produces a launch addon for ``game_type`` (or None).
69
+
70
+ Concrete providers are stateless singletons that self-register on import.
71
+ """
72
+
73
+ game_type: GameType
74
+
75
+ @abstractmethod
76
+ def addon(self, prepare: ClientPrepare) -> ProfileAddon | None:
77
+ """Resolve extra libraries/args/main-class; return None to launch vanilla."""
78
+
79
+ @classmethod
80
+ def register(cls, provider: ClientProvider) -> None:
81
+ _REGISTRY[provider.game_type] = provider
82
+
83
+ @classmethod
84
+ def for_type(cls, game_type: GameType) -> ClientProvider | None:
85
+ return _REGISTRY.get(game_type)
@@ -0,0 +1,28 @@
1
+ """Fabric client: resolve the fabric-loader profile and download its libraries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from orzmc.core.client.base import ClientPrepare, ClientProvider
6
+ from orzmc.core.fabric import Fabric
7
+ from orzmc.core.profiles import ProfileAddon
8
+ from orzmc.domain.types import GameType
9
+
10
+
11
+ class FabricProvider(ClientProvider):
12
+ game_type = GameType.FABRIC
13
+
14
+ def addon(self, prepare: ClientPrepare) -> ProfileAddon | None:
15
+ addon = Fabric(prepare.http, prepare.version).profile()
16
+ for lib in addon.libraries:
17
+ if lib.url:
18
+ prepare.download(
19
+ lib.url,
20
+ prepare.paths.client_library_path(lib.path),
21
+ f"下载 {lib.name}",
22
+ lib.sha1,
23
+ force=prepare.force_download,
24
+ )
25
+ return addon
26
+
27
+
28
+ ClientProvider.register(FabricProvider())
@@ -0,0 +1,128 @@
1
+ """Forge client: run the official installer and assemble the launch addon.
2
+
3
+ Forge ships no standalone client definition: the launch json is embedded in
4
+ the installer jar and the patched ``client`` classifier jar (the Minecraft
5
+ module the launcher needs) has **no downloadable source** — its ``url`` is
6
+ empty in ``version.json`` and Maven 404s it. It is produced by the installer's
7
+ binary-patching pipeline from the vanilla client jar.
8
+
9
+ So this provider mirrors the server side: download the installer, run it
10
+ headlessly (``--installClient``) into a temporary official-layout directory,
11
+ then harvest the generated jar plus the forge libraries into our own
12
+ ``client/libraries/`` layout. The shared ``core.forge`` adapter resolves the
13
+ version from ``promotions_slim.json`` and reads ``version.json`` from the jar.
14
+
15
+ Only the modern ForgeBootstrap era (1.20.4+ / 49.x) is supported: those
16
+ versions ship a plain ``arguments`` block with no ``{{token}}`` templating and
17
+ exactly one generated jar. Older Forge (bootstraplauncher) versions raise a
18
+ clear error instead of producing a broken launch.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from typing import Any
25
+
26
+ from orzmc.core.client.base import ClientPrepare, ClientProvider
27
+ from orzmc.core.forge import Forge, extract_version_json
28
+ from orzmc.core.profiles import ProfileAddon
29
+ from orzmc.domain.libraries import Library
30
+ from orzmc.domain.types import GameType
31
+
32
+ _BOOTSTRAP_LAUNCHER = "cpw.mods.bootstraplauncher"
33
+ _FORGE_MIN_JAVA = 17 # ForgeBootstrap-era installers require Java 17+
34
+
35
+ _MINIMAL_LAUNCHER_PROFILE = {
36
+ "authenticationDatabase": {},
37
+ "selectedUser": {"account": "", "profile": ""},
38
+ "profiles": {},
39
+ "settings": {"enableSnapshots": False, "keepLauncherOpen": False},
40
+ "version": 1,
41
+ }
42
+
43
+
44
+ class ForgeProvider(ClientProvider):
45
+ game_type = GameType.FORGE
46
+
47
+ def addon(self, prepare: ClientPrepare) -> ProfileAddon | None:
48
+ forge = Forge(prepare.http)
49
+ full = forge.latest_full_version(prepare.version)
50
+ installer = os.path.join(prepare.paths.client_dir(), "forge", f"forge-{full}-installer.jar")
51
+ prepare.download(forge.installer_url(full), installer, f"下载 Forge 安装器 {full}")
52
+
53
+ forge_json = extract_version_json(installer)
54
+ if _BOOTSTRAP_LAUNCHER in str(forge_json.get("mainClass", "")):
55
+ raise RuntimeError(
56
+ f"Forge {full} 使用旧版启动器(BootstrapLauncher),暂不支持客户端启动"
57
+ "(仅支持 ForgeBootstrap 系,1.20.4 及以上)"
58
+ )
59
+
60
+ install_dir = os.path.join(prepare.paths.client_dir(), "forge", "install")
61
+ try:
62
+ self._run_client_install(prepare, installer, full, install_dir)
63
+ libraries = self._harvest_libraries(prepare, forge_json, install_dir)
64
+ finally:
65
+ prepare.fs.remove(install_dir)
66
+
67
+ return ProfileAddon(
68
+ libraries=libraries,
69
+ jvm_args=_forge_string_args(forge_json, "jvm"),
70
+ game_args=_forge_string_args(forge_json, "game"),
71
+ main_class=forge_json.get("mainClass"),
72
+ # the harvested client jar *is* the game jar; the vanilla jar must
73
+ # not also sit on the classpath or its unpatched classes shadow it
74
+ uses_own_client_jar=True,
75
+ )
76
+
77
+ # ── internals ───────────────────────────────────────────────────────────
78
+
79
+ def _run_client_install(self, prepare: ClientPrepare, installer: str, full: str, install_dir: str) -> None:
80
+ prepare.fs.ensure_dir(install_dir)
81
+ profile = os.path.join(install_dir, "launcher_profiles.json")
82
+ if not prepare.fs.is_file(profile):
83
+ prepare.fs.write_json(profile, _MINIMAL_LAUNCHER_PROFILE)
84
+ java_bin = prepare.resolve_build_java(
85
+ max(prepare.major, _FORGE_MIN_JAVA), need_jdk=False, confirm=prepare.confirm_java
86
+ )
87
+ prepare.reporter.info(f"正在生成 Forge 客户端 {full}...")
88
+ code = prepare.process.run_stream(
89
+ [java_bin, "-jar", installer, "--installClient", install_dir],
90
+ on_line=lambda line: prepare.reporter.plain(line),
91
+ cwd=install_dir,
92
+ )
93
+ if code != 0:
94
+ raise RuntimeError(f"Forge 客户端安装失败 ({full})")
95
+
96
+ def _harvest_libraries(self, prepare: ClientPrepare, forge_json: dict[str, Any], install_dir: str) -> list[Library]:
97
+ """Move the forge libraries (incl. the generated client jar) into our layout."""
98
+ libraries: list[Library] = []
99
+ for lib in forge_json.get("libraries", []):
100
+ artifact = (lib.get("downloads") or {}).get("artifact") or {}
101
+ path = artifact.get("path")
102
+ name = lib.get("name")
103
+ if not path or not name:
104
+ continue
105
+ src = os.path.join(install_dir, "libraries", path)
106
+ if not prepare.fs.is_file(src):
107
+ continue
108
+ prepare.fs.move(src, prepare.paths.client_library_path(path))
109
+ libraries.append(
110
+ Library(
111
+ name=name,
112
+ path=path,
113
+ url=artifact.get("url") or "",
114
+ sha1=artifact.get("sha1"),
115
+ size=artifact.get("size"),
116
+ )
117
+ )
118
+ if not any(lib.path.endswith("-client.jar") for lib in libraries):
119
+ raise RuntimeError("未找到 Forge 客户端产物(client jar),安装可能不完整")
120
+ return libraries
121
+
122
+
123
+ def _forge_string_args(forge_json: dict[str, Any], key: str) -> list[str]:
124
+ """Plain-string entries of ``arguments.<key>`` (forge ships no rule dicts here)."""
125
+ return [a for a in (forge_json.get("arguments") or {}).get(key, []) if isinstance(a, str)]
126
+
127
+
128
+ ClientProvider.register(ForgeProvider())
@@ -0,0 +1,17 @@
1
+ """Vanilla client: no add-on — the plain Mojang launch definition suffices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from orzmc.core.client.base import ClientPrepare, ClientProvider
6
+ from orzmc.core.profiles import ProfileAddon
7
+ from orzmc.domain.types import GameType
8
+
9
+
10
+ class VanillaProvider(ClientProvider):
11
+ game_type = GameType.VANILLA
12
+
13
+ def addon(self, prepare: ClientPrepare) -> ProfileAddon | None:
14
+ return None
15
+
16
+
17
+ ClientProvider.register(VanillaProvider())
orzmc/core/fabric.py ADDED
@@ -0,0 +1,97 @@
1
+ """Fabric loader resolution & library download (fabric-meta API)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from orzmc.core.profiles import ProfileAddon
8
+ from orzmc.domain.libraries import Library
9
+ from orzmc.infra.http import HttpClient
10
+
11
+ META_BASE = "https://meta.fabricmc.net/v2"
12
+
13
+
14
+ class Fabric:
15
+ def __init__(self, http: HttpClient, version: str, loader: str | None = None) -> None:
16
+ self._http = http
17
+ self.version = version
18
+ self.loader = loader
19
+
20
+ def profile(self) -> ProfileAddon:
21
+ """Resolve the fabric-loader profile json for this MC version."""
22
+ loader_version = self.loader or self.latest_loader_version()
23
+ # fabric-meta stopped accepting the installer version in this URL (404
24
+ # for every combo); the loader version alone resolves the profile.
25
+ url = f"{META_BASE}/versions/loader/{self.version}/{loader_version}/profile/json"
26
+ config: dict[str, Any] = self._http.get_json(url)
27
+
28
+ libraries: list[Library] = []
29
+ for lib in config.get("libraries", []):
30
+ name = lib.get("name")
31
+ if not name:
32
+ continue
33
+ # fabric-meta's `url` is now just the repository base (e.g.
34
+ # ``https://maven.fabricmc.net/``), not a per-artifact url — the
35
+ # local path must come from the maven coordinates themselves.
36
+ path = _coordinate_path(name)
37
+ base = lib.get("url") or "https://maven.fabricmc.net/"
38
+ libraries.append(
39
+ Library(
40
+ name=name,
41
+ path=path,
42
+ url=base.rstrip("/") + "/" + path,
43
+ sha1=lib.get("sha1"),
44
+ size=lib.get("size"),
45
+ )
46
+ )
47
+
48
+ arguments = config.get("arguments", {})
49
+ jvm_args = [a for a in arguments.get("jvm", []) if isinstance(a, str)]
50
+ game_args = [a for a in arguments.get("game", []) if isinstance(a, str)]
51
+ main_class = _main_class(config)
52
+
53
+ return ProfileAddon(libraries=libraries, jvm_args=jvm_args, game_args=game_args, main_class=main_class)
54
+
55
+ def latest_loader_version(self) -> str:
56
+ """Latest stable fabric-loader version for this MC version."""
57
+ entries = self._http.get_json(f"{META_BASE}/versions/loader/{self.version}")
58
+ for entry in entries:
59
+ loader = (entry or {}).get("loader", {})
60
+ if loader.get("stable"):
61
+ return loader["version"]
62
+ if entries:
63
+ return entries[0]["loader"]["version"]
64
+ raise RuntimeError(f"Fabric 不支持 Minecraft {self.version}")
65
+
66
+ def latest_installer_version(self) -> str:
67
+ """Latest stable fabric-installer version."""
68
+ entries = self._http.get_json(f"{META_BASE}/versions/installer")
69
+ for entry in entries:
70
+ if entry.get("stable"):
71
+ return entry["version"]
72
+ return entries[0]["version"]
73
+
74
+
75
+ def _coordinate_path(name: str) -> str:
76
+ """Map maven coordinates ``group:artifact:version[:classifier]`` to a jar path."""
77
+ parts = name.split(":")
78
+ if len(parts) == 3:
79
+ group, artifact, version = parts
80
+ classifier = ""
81
+ elif len(parts) == 4:
82
+ group, artifact, version, classifier = parts
83
+ else:
84
+ return name.replace(":", "/") + ".jar"
85
+ base = f"{artifact}-{version}"
86
+ if classifier:
87
+ base += f"-{classifier}"
88
+ return f"{group.replace('.', '/')}/{artifact}/{version}/{base}.jar"
89
+
90
+
91
+ def _main_class(config: dict[str, Any]) -> str | None:
92
+ main_class = config.get("mainClass")
93
+ if isinstance(main_class, str):
94
+ return main_class
95
+ if isinstance(main_class, dict):
96
+ return main_class.get("client") or main_class.get("server")
97
+ return None
orzmc/core/forge.py ADDED
@@ -0,0 +1,44 @@
1
+ """Forge version resolution & installer helpers (Maven API — no HTML scraping).
2
+
3
+ The download page's own data source, ``promotions_slim.json``, maps a Minecraft
4
+ version to its recommended/latest Forge build. Everything else comes from the
5
+ Forge Maven repository, and the client launch definition (``version.json``) is
6
+ extracted from the installer jar it is shipped inside. Shared by the client and
7
+ server Forge providers.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import zipfile
14
+ from typing import Any
15
+
16
+ from orzmc.infra.http import HttpClient
17
+
18
+ PROMOTIONS_URL = "https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json"
19
+ MAVEN_BASE = "https://maven.minecraftforge.net/net/minecraftforge/forge"
20
+
21
+
22
+ class Forge:
23
+ """Resolve the Forge version for a MC version and locate installer artifacts."""
24
+
25
+ def __init__(self, http: HttpClient) -> None:
26
+ self._http = http
27
+
28
+ def latest_full_version(self, mc_version: str) -> str:
29
+ """``<mc>-<build>`` (e.g. ``1.20.4-49.2.8``) for ``mc_version``."""
30
+ promos = self._http.get_json(PROMOTIONS_URL).get("promos") or {}
31
+ for key in (f"{mc_version}-latest", f"{mc_version}-recommended"):
32
+ build = promos.get(key)
33
+ if build:
34
+ return f"{mc_version}-{build}"
35
+ raise RuntimeError(f"未找到 Minecraft {mc_version} 的 Forge 版本")
36
+
37
+ def installer_url(self, full_version: str) -> str:
38
+ return f"{MAVEN_BASE}/{full_version}/forge-{full_version}-installer.jar"
39
+
40
+
41
+ def extract_version_json(installer_path: str) -> dict[str, Any]:
42
+ """Read the ``version.json`` embedded in a Forge installer jar."""
43
+ with zipfile.ZipFile(installer_path) as zf, zf.open("version.json") as f:
44
+ return json.load(f)