Files
airdef/tools/airdef.py
T
chexit ccc3e20902
build / mod (push) Successful in 4m4s
release / windows (push) Successful in 23m13s
Playtest fixes: smoke by engine, searchlights, far launch sites that load their chunks, air defence balance, last chance and radar-cued shots, radars in smart raids, echo, Windows release build from a tag
Smoke: one rule by engine, no heat on trails, no trail on P1-SUN. Searchlights: two lamps a target, off with an empty sky. Halos fade before their reach and their sum is limited. Launch sites load chunks through the server thread, go off-map as a last resort, and reach 4000 blocks. Air defence: ranges x1.5, shots grow with urgency, claims released, /airdef lastchance, long shots on a big radar's tracks. Build: rust-toolchain.toml, airdef.py check, generator manifest and --check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:44:14 +03:00

301 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
python3 tools/airdef.py check # совпадают ли форк, упаковщик, уровень API мода и ресурсы
Переменные окружения: HORIZON_HOME (папка игры), HORIZON_RESOURCES (подготовленные ресурсы
Minecraft 26.3), AIRDEF_GAME (готовый horizon-client вместо собранного).
"""
import glob
import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import zipfile
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 fork_api():
"""Новейший уровень API форка и его таблица функций (horizon-mods/src/api_level.rs); None без форка."""
try:
text = (FORK / "crates" / "horizon-mods" / "src" / "api_level.rs").read_text(encoding="utf-8")
except OSError:
return None
try:
level = int(re.search(r"pub const LEVEL: u32 = (\d+);", text).group(1))
table = text[text.index("pub const FUNCTIONS"):]
table = table[:table.index("];")]
except (AttributeError, ValueError):
return None
return level, [(name, int(lvl)) for name, lvl in re.findall(r'\("([a-z.\-]+)", (\d+)\)', table)]
def needed_level(wasm: bytes, table) -> tuple:
"""Уровень, который нужен модулю: самая новая функция, которую он импортирует (как api_level::needed).
В компоненте имя функции записано как 0x00, длина, UTF-8 имя; по этим байтам и ищем."""
need = (0, None)
for name, level in table:
part = name.split(".", 1)[1].encode()
if level > need[0] and b"\x00" + bytes([len(part)]) + part in wasm:
need = (level, name)
return need
def game_level():
"""Уровень, который игра записала в себя (метка horizon-mod-api-level:NNNN;); None, если игры нет.
Игра без метки считается уровня 0, как в api_level::read_mark."""
try:
exe = built_game()
except SystemExit:
return None
if not exe.exists():
return None
found = re.search(rb"horizon-mod-api-level:(\d{4});", exe.read_bytes())
return int(found.group(1)) if found else 0
def check():
"""Говорит прямо, совпадают ли форк, упаковщик, уровень API мода с игрой и ресурсы. Код 0 — да, 1 — нет."""
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
patches = sorted((ROOT / "horizon-patches").glob("*.patch"))
stamp = fork_stamp(commit, patches)
problems = []
def line(name, ok, text):
print(f"[{'ok' if ok else 'нет'}] {name}: {text}")
if not ok:
problems.append(name)
stamp_file = FORK / ".git" / "airdef-stamp"
fork_ok = stamp_file.exists() and stamp_file.read_text().strip() == stamp
line("форк", fork_ok, f"коммит {commit[:12]}, патчей {len(patches)}" if fork_ok else
"horizon/ не собран из HORIZON_COMMIT и horizon-patches/: python3 tools/airdef.py fork")
tool = shutil.which("cargo-horizon-mod") or str(Path.home() / ".cargo" / "bin" / ("cargo-horizon-mod" + EXE))
packer_stamp = FORK / ".git" / "airdef-packer-stamp"
packer_ok = Path(tool).exists() and packer_stamp.exists() and packer_stamp.read_text().strip() == stamp
line("упаковщик", packer_ok, "собран из этого форка" if packer_ok else
"не установлен или собран из другого форка: python3 tools/airdef.py mod")
api = fork_api()
try:
mod_file = hmod()
except SystemExit:
mod_file = None
if api is None:
line("уровень API", False, "нет или не читается horizon/crates/horizon-mods/src/api_level.rs: python3 tools/airdef.py fork")
elif mod_file is None:
line("уровень API", False, "нет airdef.hmod: python3 tools/airdef.py mod")
else:
level, table = api
with zipfile.ZipFile(mod_file) as z:
wasm = z.read("mod.wasm") if "mod.wasm" in z.namelist() else b""
need, function = needed_level(wasm, table)
game = game_level()
text = f"мод нужен {need} ({function}), форк {level}, игра " + ("не собрана" if game is None else str(game))
if game is None:
line("уровень API", False, text + ": python3 tools/airdef.py game")
else:
# Сравнивается уровень, не код: одинаковый уровень не доказывает одинаковые патчи.
supported = need <= game and game == level
line("уровень API", supported, text if supported else
text + ": игра не поддерживает мод или собрана из другого форка")
try:
res = resources()
except SystemExit as e:
res = None
line("ресурсы", False, str(e.code))
if res is not None:
try:
preparer = (FORK / "crates" / "horizon-prepare" / "src" / "lib.rs").read_text(encoding="utf-8")
schema = int(re.search(r"pub const SCHEMA: i64 = (\d+);", preparer).group(1))
except (OSError, AttributeError):
schema = None
try:
manifest = json.loads((res / "horizon-resources.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
manifest = {}
ok = schema is not None and manifest.get("schema") == schema and manifest.get("minecraft") == "26.3"
line("ресурсы", ok, f"{res}, схема {schema}, Minecraft 26.3" if ok else
f"{res}: схема {manifest.get('schema')}, Minecraft {manifest.get('minecraft')}; нужны схема {schema} и Minecraft 26.3")
if problems:
sys.exit("Не совпадает: " + ", ".join(problems) + ".")
print("Всё совпадает: форк, упаковщик, уровень API мода и ресурсы.")
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, "check": check}
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()