4d4e1682f5
build / mod (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
8.5 KiB
Python
186 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
||
"""AirDef: сборка, установка и запуск на Windows, macOS и Linux. Только стандартная библиотека Python.
|
||
|
||
python3 tools/airdef.py fork # скачать Horizon нужной версии и наложить патчи форка (horizon/)
|
||
python3 tools/airdef.py game # собрать игру (форк) — нужен Rust
|
||
python3 tools/airdef.py mod # собрать мод airdef.hmod — нужен Rust с wasm32
|
||
python3 tools/airdef.py install # положить мод в папку игры и включить
|
||
python3 tools/airdef.py play [аргументы игры] # запустить форк на папке игры
|
||
python3 tools/airdef.py all # всё по порядку: fork, game, mod, install
|
||
|
||
Переменные окружения: HORIZON_HOME (папка игры), HORIZON_RESOURCES (подготовленные ресурсы
|
||
Minecraft 26.3), AIRDEF_GAME (готовый horizon-client вместо собранного).
|
||
"""
|
||
import glob
|
||
import hashlib
|
||
import os
|
||
import platform
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
FORK = ROOT / "horizon"
|
||
UPSTREAM = "https://github.com/ludovic111/horizon.git"
|
||
EXE = ".exe" if os.name == "nt" else ""
|
||
|
||
|
||
def data_dir() -> Path:
|
||
"""Как `dirs::data_dir()` в Horizon."""
|
||
system = platform.system()
|
||
if system == "Windows":
|
||
return Path(os.environ["APPDATA"])
|
||
if system == "Darwin":
|
||
return Path.home() / "Library" / "Application Support"
|
||
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
||
|
||
|
||
def game_home() -> Path:
|
||
return Path(os.environ.get("HORIZON_HOME") or data_dir() / "horizon")
|
||
|
||
|
||
def run(cmd, cwd=None, env=None):
|
||
print("+", " ".join(str(c) for c in cmd), flush=True)
|
||
subprocess.run([str(c) for c in cmd], cwd=cwd, env=env, check=True)
|
||
|
||
|
||
def cargo() -> str:
|
||
found = shutil.which("cargo") or str(Path.home() / ".cargo" / "bin" / ("cargo" + EXE))
|
||
if not Path(found).exists() and not shutil.which("cargo"):
|
||
sys.exit("Нужен Rust: https://rustup.rs (затем: rustup target add wasm32-unknown-unknown)")
|
||
return found
|
||
|
||
|
||
def fork_stamp(commit: str, patches) -> str:
|
||
"""Что должно стоять в horizon/: коммит апстрима и содержимое патчей."""
|
||
h = hashlib.sha256(commit.encode())
|
||
for p in patches:
|
||
h.update(p.name.encode())
|
||
h.update(p.read_bytes())
|
||
return h.hexdigest()
|
||
|
||
|
||
def fork():
|
||
"""horizon/ = апстрим на коммите HORIZON_COMMIT + патчи horizon-patches/.
|
||
|
||
Ветка airdef пересобирается, когда коммит или патчи изменились (после обновления репозитория);
|
||
прежняя остаётся в ветке airdef-prev."""
|
||
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
|
||
patches = sorted((ROOT / "horizon-patches").glob("*.patch"))
|
||
stamp = fork_stamp(commit, patches)
|
||
stamp_file = FORK / ".git" / "airdef-stamp"
|
||
if not (FORK / ".git").exists():
|
||
# Без CRLF на Windows: патчи накладываются на файлы как в апстриме.
|
||
run(["git", "clone", "-c", "core.autocrlf=false", "--filter=blob:none", UPSTREAM, FORK])
|
||
branch = subprocess.run(["git", "-C", FORK, "branch", "--show-current"], capture_output=True, text=True).stdout.strip()
|
||
if branch == "airdef" and stamp_file.exists() and stamp_file.read_text().strip() == stamp:
|
||
print("horizon/: ветка airdef на нужных коммите и патчах")
|
||
return
|
||
dirty = subprocess.run(["git", "-C", FORK, "status", "--porcelain", "--untracked-files=no"],
|
||
capture_output=True, text=True).stdout.strip()
|
||
if dirty:
|
||
sys.exit("horizon/: есть незакоммиченные изменения, а движок надо обновить. Закоммить или убери их и запусти снова.")
|
||
exists = subprocess.run(["git", "-C", FORK, "rev-parse", "--verify", "-q", "refs/heads/airdef"],
|
||
capture_output=True).returncode == 0
|
||
if exists:
|
||
print("horizon/: коммит или патчи изменились, ветка airdef пересобирается (прежняя: airdef-prev)")
|
||
run(["git", "-C", FORK, "branch", "-f", "airdef-prev", "airdef"])
|
||
run(["git", "-C", FORK, "fetch", "--quiet", "origin", commit])
|
||
run(["git", "-C", FORK, "checkout", "-B", "airdef", commit])
|
||
if patches:
|
||
run(["git", "-C", FORK, "-c", "user.name=airdef", "-c", "user.email=airdef@localhost",
|
||
"am", "--3way", *patches])
|
||
stamp_file.write_text(stamp + "\n")
|
||
|
||
|
||
def game():
|
||
if not (FORK / "Cargo.toml").exists():
|
||
fork()
|
||
run([cargo(), "build", "--profile", "fast", "-p", "horizon-client", "--features", "mods"], cwd=FORK)
|
||
|
||
|
||
def built_game() -> Path:
|
||
if os.environ.get("AIRDEF_GAME"):
|
||
return Path(os.environ["AIRDEF_GAME"])
|
||
for name in (FORK / "target" / "fast" / ("horizon-client" + EXE), ROOT / ("horizon-client" + EXE)):
|
||
if name.exists():
|
||
return name
|
||
sys.exit("Нет собранной игры: python3 tools/airdef.py game (или AIRDEF_GAME=путь к horizon-client)")
|
||
|
||
|
||
def mod():
|
||
tool = shutil.which("cargo-horizon-mod") or Path.home() / ".cargo" / "bin" / ("cargo-horizon-mod" + EXE)
|
||
if not (FORK / "Cargo.toml").exists():
|
||
fork()
|
||
# Упаковщик должен быть от этого форка: старый не знает новых уровней API.
|
||
stamp_file = FORK / ".git" / "airdef-packer-stamp"
|
||
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
|
||
stamp = fork_stamp(commit, sorted((ROOT / "horizon-patches").glob("*.patch")))
|
||
if not Path(tool).exists() or not stamp_file.exists() or stamp_file.read_text().strip() != stamp:
|
||
# Without trial runs (wasmtime): building a mod needs only packing, a fraction of the compile.
|
||
run([cargo(), "install", "--locked", "--force", "--no-default-features", "--path", FORK / "crates" / "cargo-horizon-mod"])
|
||
stamp_file.write_text(stamp + "\n")
|
||
run([cargo(), "horizon-mod", "build"], cwd=ROOT / "airdef-mod")
|
||
|
||
|
||
def hmod() -> Path:
|
||
for p in (ROOT / "airdef-mod" / "target" / "horizon-mod" / "airdef.hmod", ROOT / "airdef.hmod"):
|
||
if p.exists():
|
||
return p
|
||
sys.exit("Нет airdef.hmod: python3 tools/airdef.py mod")
|
||
|
||
|
||
def install():
|
||
home = game_home()
|
||
mods = home / "horizon-mods"
|
||
mods.mkdir(parents=True, exist_ok=True)
|
||
shutil.rmtree(mods / "airdef", ignore_errors=True) # первая версия мода была папкой
|
||
shutil.copy2(hmod(), mods / "airdef.hmod")
|
||
listing = home / "horizon-mods.txt"
|
||
lines = listing.read_text().splitlines() if listing.exists() else []
|
||
lines = [l for l in lines if not l.startswith("mod.airdef:")] + ["mod.airdef:true"]
|
||
listing.write_text("\n".join(lines) + "\n")
|
||
print(f"Мод установлен в {mods} и включён")
|
||
|
||
|
||
def resources() -> Path:
|
||
if os.environ.get("HORIZON_RESOURCES"):
|
||
return Path(os.environ["HORIZON_RESOURCES"])
|
||
found = sorted(glob.glob(str(data_dir() / "horizon-launcher" / "versions" / "*" / ".local" / "horizon-client" / "26.3")))
|
||
local = FORK / ".local" / "horizon-client" / "26.3"
|
||
if local.exists():
|
||
found.append(str(local))
|
||
if not found:
|
||
sys.exit("Не найдены ресурсы игры. Установи Horizon через лаунчер и запусти его один раз "
|
||
"(он подготовит ресурсы из твоего Minecraft 26.3), либо задай HORIZON_RESOURCES.")
|
||
return Path(found[-1])
|
||
|
||
|
||
def play(args):
|
||
exe = built_game().resolve()
|
||
home = game_home()
|
||
home.mkdir(parents=True, exist_ok=True)
|
||
# Игра хранит настройки, моды и миры в текущей папке.
|
||
run([exe, "--resources", resources(), *args], cwd=home)
|
||
|
||
|
||
def main():
|
||
if len(sys.argv) < 2:
|
||
sys.exit(__doc__)
|
||
cmd, rest = sys.argv[1], sys.argv[2:]
|
||
steps = {"fork": fork, "game": game, "mod": mod, "install": install}
|
||
if cmd == "play":
|
||
play(rest)
|
||
elif cmd == "all":
|
||
for step in ("fork", "game", "mod", "install"):
|
||
steps[step]()
|
||
elif cmd in steps:
|
||
steps[cmd]()
|
||
else:
|
||
sys.exit(__doc__)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|